Constraining literal types with generics in TypeScript

Let's say you need an object with the shape of Record<string, { s: string } | { n: number } | { b: boolean }>. You fire up your IDE and write down something like this:

type A = { s: string };
type B = { n: number };
type C = { b: boolean };

type MyObject = Record<string, A | B | C>;

Great, you can now type check your object literals:

const o1: MyObject = {
  a: { s: 1 }, // error: Type 'number' is not assignable to type 'string'.
};

// all good
const o2: MyObject = {
  a: { s: "str" },
};

Some time later you decide that you need to know the type of o.a, but it can't be inferred! Due to the MyObject type reference, the object literal type information is lost and all you are left with is A | B | C:

type T = typeof o2.a; // => A | B | C

Moreover, because string is used as an indexed access type of Record, TS will not warn you about the non-existent property access:

// TS guess:      `A | B | C`
// Harsh reality: `undefined`
const value = o2.j;

The autocomplete is also not available in this case.

Fortunately, we can leverage the power of generics to both type check the object literal and preserve type information:

type Constraint = Record<string, A | B | C>;

function identity<T extends Constraint>(x: T): T {
  return x;
}

const o = identity({
  a: { s: "a" },
});

type T = typeof o.a; // => { s: string }

The extends clause of the type parameter enforces the correct type of the object:

const o = identity({
  a: { s: 1 }, // error: Type 'number' is not assignable to type 'string'.
});

At the same time, the literal type information is preserved because the identity function returns exactly what it have received: T, which is the (literal) type of the object literal.

25