Constraining literal types with generics in TypeScript

UPD: TypeScript 4.9 was announced on September 23, 2022 and the new satisfies operator has made this article obsolete.

Before TS 4.9:

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

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 }

After TS 4.9:

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

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

const o = {
  a: { s: "a" },
} satisfies Constraint;

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

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" },
};

Open in TS playground →

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

Open in TS playground →

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;

Open in TS playground →

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 }

Open in TS playground →

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'.
});

Open in TS playground →

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.

Read more on generic constraints in the TS handbook: https://www.typescriptlang.org/docs/handbook/2/generics.html#generic-constraints