/ #typescript 

TypeScript in the Trenches: Utility types and type manipulation patterns for application development

7 advanced types and patterns that I’ve used as part of TypeScript application development.

These patterns help unlock new levels of type safety and maintainability in TypeScript.

Table of Contents

All examples: github.com/HugoDF/real-world-ts.

1. Enum to string union

TypeScript enums have a number of quirks and are not recommended for use, but a lot of existing systems use them.

The enum type is not the same as the union of the values of the enum, and this allows you to convert the enum to the union of values.

This patterns is useful for React component props, in that it avoids having to leak enum values. Eg. In your design system/UI library you dont want type={PrivateEnum.type} instead you want type="something" where something is in the PrivateEnum values.

import { expectTypeOf } from 'expect-type';

export enum TransactionTypes {
  CC = 'CC',
  BACS = 'BACS',
}

export type AsValue = `${TransactionTypes}`;

expectTypeOf<AsValue>().toEqualTypeOf<'CC' | 'BACS'>();
function MyComp({
  txType,
  txTypeStr,
}: {
  txType: TransactionTypes;
  txTypeStr: AsValue;
}) {
  //...
}
MyComp({ txType: TransactionTypes.CC, txTypeStr: 'CC' });
// @ts-expect-error Type '"CC"' is not assignable to type 'TransactionTypes'.
MyComp({ txType: 'CC', txTypeStr: TransactionTypes.CC });

2. Omit/Pick from Object

“Removing” or “selecting” fields from an object is quite widespread by this point but very useful.

import { expectTypeOf } from 'expect-type';
export type Product = {
  price: number;
  description: string;
};

export type Cart = {
  lineItems: {
    product: Product;
    amount: number;
  };
  total: number;
};

expectTypeOf<Pick<Cart, 'total'>>().toEqualTypeOf<{ total: number }>();
expectTypeOf<Omit<Cart['lineItems'], 'amount'>>().toEqualTypeOf<{
  product: Product;
}>();

Why would you want to do this? It reduces copy-paste and keeps the copies synced. Due to the way TypeScript type-checks the structure/contents (2 types with the same content may as well be the same type as far as TypeScript is concerned), there aren’t really other disadvantages.

3. Include/Exclude TypeScript type(s) from Union

Include/Exclude can help remove members from a TypeScript union.

import { expectTypeOf } from 'expect-type';
type ValueType1 = { myField: string };
type ValueType2 = {
  myOtherField: string;
};
type ValueType3 = { myField2: string };
type PossibleValues = ValueType1 | ValueType2 | ValueType3;

expectTypeOf<Exclude<PossibleValues, ValueType1>>().toEqualTypeOf<
  ValueType2 | ValueType3
>();

This pattern is useful when some functionality is only relevant to a subset or superset of the types in the union.

4. Zipping a type to a union

TS has a sort of loop construct using NonNullable<{ [K in keyof Fields]: /* use K here */ }>[keyof FIELDS]. Which is useful for example if you want key-value pairwise types.

import { expectTypeOf } from 'expect-type';

interface Fields {
  amount: number;
  formattedAmount: string;
}
type FieldDefs = NonNullable<{
  [K in keyof Fields]: {
    name: K;
    value: Fields[K];
  };
}>[keyof Fields];

expectTypeOf<FieldDefs>().toEqualTypeOf<
  { name: 'amount'; value: number } | { name: 'formattedAmount'; value: string }
>();

Why is this useful? It keeps the type stricter, { name: 'amount', value: number } ensures the name value matches the value type.

A more naive implementation doesn’t have this strictness on the union:

type FieldNames = keyof Fields;
type FieldValues = Fields[keyof Fields];

expectTypeOf<FieldNames>().toEqualTypeOf<'amount' | 'formattedAmount'>();
expectTypeOf<FieldValues>().toEqualTypeOf<number | string>();

Note the lack of relationship between amount <-> number and formattedAmount <-> string, this means the type is not as strict as it could be.

function setFieldNaive(name: FieldNames, value: FieldValues) {}
setFieldNaive('amount', '200'); // no error

function setFieldStrict(update: FieldDefs) {}
// @ts-expect-error
// Argument of type '{ name: "amount"; value: string; }' is not assignable to parameter of type 'FieldDefs'.
//  Types of property 'value' are incompatible.
//    Type 'string' is not assignable to type 'number'.
setFieldStrict({ name: 'amount', value: '200' });
// no error
setFieldStrict({ name: 'amount', value: 200 });

5. Simulating tuples

In TypeScript, you can define arrays of explicit lengths using bracket notation, eg. type MyPair = [number, string].

This can be useful to simulate tuples using an enum with 2 values.

enum DoorState {
  OPEN = 'OPEN',
  CLOSED = 'CLOSED',
}
function getInverseValue<T>(val: T, possibleVals: [T, T]): T {
  return possibleVals[0] === val ? possibleVals[1] : possibleVals[0];
}

import assert from 'node:assert';
import test from 'node:test';
test('inverts the enum value', () => {
  assert.equal(
    getInverseValue<DoorState>(DoorState.OPEN, [
      DoorState.OPEN,
      DoorState.CLOSED,
    ]),
    DoorState.CLOSED
  );
  assert.equal(
    getInverseValue<DoorState>(DoorState.CLOSED, [
      DoorState.OPEN,
      DoorState.CLOSED,
    ]),
    DoorState.OPEN
  );
});

6. Enum values to keys reverse mapping

This is a trick more than something you should use often. I would leave a comment explaining how/why code using this behaviour works because it’s non obvious (a lot of people are unaware of this feature).

A place where this is particularly useful is with protobuf definitions whose labels codegen to enums with numeric values.

enum PaymentType {
  CC = 1,
  BACS = 2,
}
enum Platform {
  WEB,
  MOBILE,
}
enum PlatformStringValues {
  WEB = 'web',
  MOBILE = 'mobile',
}

const paymentType = PaymentType.CC;
const platform = Platform.MOBILE;
const platformString = Platform.WEB;

import { expectTypeOf } from 'expect-type';
import assert from 'node:assert';
import test from 'node:test';

test('reverse mapping of enum with set numeric values', () => {
  assert.equal(PaymentType[paymentType], 'CC');
});
test('reverse mapping of enum with implicit numeric values', () => {
  assert.equal(Platform[platform], 'MOBILE');
});
test('reverse mapping of enum with string values - does not work', () => {
  expectTypeOf(PlatformStringValues).not.toHaveProperty(platformString);
  // @ts-expect-error
  // Element implicitly has an 'any' type because expression of type 'Platform.WEB' can't be used to index type 'typeof PlatformStringValues'.
  // Property '[Platform.WEB]' does not exist on type 'typeof PlatformStringValues'
  assert.equal(PlatformStringValues[platformString], undefined);
});

Docs: https://www.typescriptlang.org/docs/handbook/enums.html#reverse-mappings

7. Exhaustive switch asserting type never in default statement

When using enums or unions, it’s possible to ensure every possible value is handled in a switch statement over it by using an assertNever(value: never) function.

type PlatformType = 'MOBILE' | 'WEB';
function generateAnalyticsPlatform(platform: PlatformType) {
  switch (platform) {
    case 'MOBILE':
      return 'mobile';
    case 'WEB':
      return 'web';
    default:
      assertNever(platform);
  }
}

function assertNever(value: never) {
  if (value) {
    throw new Error(`Unexpected value "${value}"`);
  }
}

import assert from 'node:assert';
import test from 'node:test';

[
  { platformValue: 'WEB', expected: 'web' } as const,
  { platformValue: 'MOBILE', expected: 'mobile' } as const,
].map(({ platformValue, expected }) => {
  test(`generateAnalyticsPlatform(${platformValue}) outputs '${expected}'`, () => {
    assert.equal(generateAnalyticsPlatform(platformValue), expected);
  });
});

Bonus: expect-type package to test/assert on TypeScript types

You’ll have noticed that the examples use the expect-type npm package, the same assertions are built into vitest (vitest pulls in expect-type).

Author

Hugo Di Francesco

Co-author of "Professional JavaScript", "Front-End Development Projects with Vue.js" with Packt, "The Jest Handbook" (self-published). Hugo runs the Code with Hugo website helping over 100,000 developers every month and holds an MEng in Mathematical Computation from University College London (UCL). He has used JavaScript extensively to create scalable and performant platforms at companies such as Canon, Elsevier and (currently) Eurostar.

Interested in Alpine.js?

Power up your debugging with the Alpine.js Devtools Extension for Chrome and Firefox. Trusted by over 15,000 developers (rated 4.5 ⭐️).