TypeScript syntax from hell

2022-07-04

Now that my firstborn has established a sleeping routine, I can resume blogging.

TypeScript has a powerful type system. For day-to-day usage it's actually a bit too powerful in my opinion. In this post I'll explore an uncommon use case for an already uncommon part of the language, namely the infer keyword.

General description

The documentation gives a brief introduction from which we learn that:

  • infer is only valid within a conditional type expression
  • It lets you extract types used in other types like so:
  type PromiseResult<T> = T extends Promise<infer A> ? A : never;

  type Result = PromiseResult<Promise<{a: string;}>>; // Result = {a: string;}

This is useful in and of itself, but pretty benign.

Example

Consider this innocent-looking one-liner:

function pickFields(input: {[key: string]: any}, ...fields: string[]) {
  return fields.map(item => input[item]);
}

Pretty straightforward - given an object and a list of keys return an array of fields picked from that object. Now how to describe its return type?

We could go the obvious route and do this:

function pickFields<T>(input: T, ...fields: (keyof T)[]): T[keyof T][] {
  return fields.map(item => input[item]);
}

Which is fine, but doesn't tell us much about the result, aside from the fact that we get an array of unknown length that has some fields from the input type.

Can we do better? Yes we can!

Basic solution

Let's start off by describing the type in natural language.

The return type of pickFields is an array of the same length as the fields argument with the exact type in each position explicitly stated.

From that we can deduce that the function's type signature is going to look more or less like this:

type PickedFields<Type, T extends (keyof Type)[]> = unknown;

function pickFields<T, F extends (keyof T)[]>(input: T, ...fields: F): PickedFields<T, F>;
function pickFields<T>(input: T, ...fields: (keyof T)[]): any {
  return fields.map(item => input[item]);
}

Note the usage of function overload.

Now that we have the input types, let's examine the output type case by case:

Passing just the input argument to pickFields should yield an empty array, so:

type PickedFields<Type, T extends (keyof Type)[]> = T extends [] ? [] : unknown;

Passing a single fields entry gives us an array of size 1:

type PickedFields<Type, T extends (keyof Type)[]> =
  T extends [] ? [] :
  T extends [(keyof Type)] ? [Type[T[0]]] : unknown;

We can extend this to any number of arguments:

type PickedFields<Type, T extends (keyof Type)[]> =
  T extends [] ? [] :
  T extends [(keyof Type)] ? [Type[T[0]]] :
  T extends [(keyof Type), (keyof Type)] ? [Type[T[0]], Type[T[1]]] :
  T extends [(keyof Type), (keyof Type), (keyof Type)] ? [Type[T[0]], Type[T[1]], Type[T[2]]] : unknown;

Open in TypeScript Playground

But that quickly becomes unwieldy and it doesn't solve the general case. Clearly we're in need of a loop of some kind, but we (fortunately?) don't have that in the type system.

Keeping it infernal

What we do have though are recursive conditional types, introduced in TS 4.1. Using them it's possible to create a recursive type solving the general case:

type PickedFields<Type, F extends (keyof Type)[]> =
  F extends [] ? [] :
  F extends [first: keyof Type, ...rest: infer R]
    ? [ Type[F[0]], ...(R extends (keyof Type)[] ? PickedFields<Type, R> : [])]
    : never;

Line by line:

  1. Type definition along with input types.
  2. If F is an empty array - output an empty array type.
  3. Otherwise check if F is an array, where the first element is a key of Type and the rest is of some (inferred) type referred to as R.
  4. Output an array with the first element having the appropriate type and the rest defined as a PickedFields type, but using R, which is just F, but without the first element.
  5. This should never happen.

TypeScript doesn't narrow-down generic types in conditional expressions, so even though we know that first has to be a keyof Type, considering that F extends (keyof Type)[], we still have to explicitly state that.

Meanwhile R might be (keyof Type)[] or it could be an empty array, so infer presents its type as unknown[].

Full example

type PickedFields<Type, F extends (keyof Type)[]> =
  F extends [] ? [] :
  F extends [first: keyof Type, ...rest: infer R]
    ? [ Type[F[0]], ...(R extends (keyof Type)[] ? PickedFields<Type, R> : [])]
    : never;

function pickFields<T, F extends (keyof T)[]>(input: T, ...fields: F): PickedFields<T, F>;
function pickFields<T>(input: T, ...fields: (keyof T)[]): any {
  return fields.map(item => input[item]);
}

interface ExampleInterface {
  a: string;
  b: number;
  c: symbol;
}

const example: ExampleInterface = {
  a: 'test',
  b: 1,
  c: Symbol()
};

// typeof exampleResult == [symbol, number, string]
const exampleResult = pickFields(example, 'c', 'b', 'a');

Open in TypeScript Playground