Computing with tuples in TypeScript

[2025-01-29] dev, typescript
(Ad, please don’t block)

JavaScript’s Arrays are so flexible that TypeScript provides two different kinds of types for handling them:

  • Array types for arbitrary-length sequences of values that all have the same type – e.g.: Array<string>
  • Tuple types for fixed-length sequences of values where each one may have a different type – e.g.: [number, string, boolean]

In this blog post, we look at the latter – especially how to compute with tuples at the type level.

Notation used in this blog post  

For showing inferred types in source code, I use the npm package ts-expect – e.g.:

// Types of values
expectType<string>('abc');
expectType<number>(123);

// Equality of types
type Pair<T> = [T, T];
expectType<TypeEqual<
  Pair<string>, [string,string]
>>(true);

The syntax of tuple types  

Basic syntax  

Tuple types have this syntax:

[ Required, Optional?, ...RestElement[] ]
  • First, zero or more required elements.
  • Then, zero or more optional elements.
  • At the end, optionally, a single rest element.

Examples:

type T = [string, boolean?, ...number[]];
const v1: T = ['a', true, 1, 2, 3];
const v2: T = ['a', true];
const v3: T = ['a'];
// @ts-expect-error: Type '[]' is not assignable to type 'T'.
const v4: T = [];

There is one additional rule: required elements can appear after a rest element – but only if there is no optional element before them:

type T1 = [number, ...boolean[], string]; // OK
// @ts-expect-error: A required element cannot follow
// an optional element.
type T2 = [number?, ...boolean[], string];

Optional elements can only be omitted at the end  

type T = [string, boolean?, ...number[]];

const v1: T = ['a', false, 1, 2, 3]; // OK
// @ts-expect-error: Type 'number' is not assignable to
// type 'boolean | undefined'.
const v2: T = ['a', 1, 2, 3];
const v3: T = ['a', undefined, 1, 2, 3]; // OK

Note that this is similar to how JavaScript handles parameters and destructuring – e.g.:

function f(x, y=3, ...z) {
  return {x,y,z};
}

If we want to enable omitting elements in the middle, we can use a union:

// The `boolean` element can be omitted:
type T =
  | [string, boolean, ...number[]]
  | [string, ...number[]]
;
const v1: T = ['a', false, 1, 2, 3]; // OK
const v2: T = ['a', 1, 2, 3]; // OK

If there is a second parameter, it is assigned to y and does not become an element of z.

Variadic tuple elements  

Variadic means “has variable (not fixed) arity”. The arity of a tuple is its length.

Variadic elements (or spread elements) enable spreading into tuples at the type level:

type Tuple1 = ['a', 'b'];
type Tuple2 = [1, 2];
expectType<TypeEqual<
  [true, ...Tuple1, ...Tuple2, false], // type expression
  [ true, 'a', 'b', 1, 2, false ] // result
>>(true);

Compare that to spreading in JavaScript:

type tuple1 = ['a', 'b'];
type tuple2 = [1, 2];
assert.deepEqual(
  [true, ...tuple1, ...tuple2, false], // expression
  [ true, 'a', 'b', 1, 2, false ] // result
);

The type that is spread is usually a type variable and must be assignable to readonly any[] – i.e., it must be an Array or a tuple. It can have any length – hence the term “variadic”. The pull request “Variadic tuple types” describes spreading like this:

Intuitively, a variadic element ...T is a placeholder that is replaced with one or more elements through generic type instantiation.

Normalization of instantiated generic tuple types  

The result of spreading is adjusted so that it always fits the shape described at the beginning of this section. To explore how that works, we’ll use the utility types Spread1 and Spread2:

type Spread1<T extends unknown[]> = [...T];
type Spread2<T1 extends unknown[], T2 extends unknown[]> =
  [...T1, ...T2]
;

// A tuple with only a spread Array becomes an Array:
expectType<TypeEqual<
  Spread1<Array<string>>,
  string[]
>>(true);

// If an Array is spread at the end, it becomes a rest element:
expectType<TypeEqual<
  Spread2<['a', 'b'], Array<number>>,
  ["a", "b", ...number[]]
>>(true);

// If two Arrays are spread, they are merged so that there
// is at most one rest element:
expectType<TypeEqual<
  Spread2<Array<string>, Array<number>>,
  [...(string | number)[]]
>>(true);

// Optional elements after an Array are merged into it:
expectType<TypeEqual<
  Spread2<Array<string>, [number?, boolean?]>,
  (string | number | boolean | undefined)[]
>>(true);

// Optional elements `T` before required ones become `undefined|T`:
expectType<TypeEqual<
  Spread2<[string?], [number]>,
  [string | undefined, number]
>>(true);

// Required elements between Arrays are also merged:
expectType<TypeEqual<
  Spread2<[boolean, ...number[]], [string, ...bigint[]]>,
  [boolean, ...(string | number | bigint)[]]
>>(true);

Note that we can only spread a type T if it is constrained via extends to an Array type:

type Spread1a<T extends unknown[]> = [...T]; // OK
// @ts-expect-error: A rest element type must be an array type.
type Spread1b<T> = [...T];

Labeled tuple elements  

We can also specify labels for tuple elements:

type Interval = [start: number, end: number];

If one element is labeled, all elements must be labeled. For optional elements, the syntax changes with labels – the question mark (?) is added to the label, not the type (TypeScript will tell you during editing if you do it wrong):

type Tuple1 = [string, boolean?, ...number[]];
type Tuple2 = [requ: string, opt?: boolean, ...rest: number[]];

What do labels do? Not much: They help with autocompletion and are preserved by some type operations but have no other effect in the type system:

  • We can’t derive anything from them (e.g. to derive an options object from normal parameters).
  • They don’t affect type compatibility etc.

Therefore: If names matter, you should use an object type.

Extracted function parameters are labeled  

If we extract function parameters, we get labeled tuple elements:

expectType<TypeEqual<
  Parameters<(sym: symbol, bool: boolean) => void>,
  [sym: symbol, bool: boolean]
>>(true);

Note that there is no way to check what the actual tuple element labels are – these checks succeed too:

// Different labels
expectType<TypeEqual<
  Parameters<(sym: symbol, bool: boolean) => void>,
  [HELLO: symbol, EVERYONE: boolean]
>>(true);

// No labels
expectType<TypeEqual<
  Parameters<(sym: symbol, bool: boolean) => void>,
  [symbol, boolean]
>>(true);

Use case: overloading  

TypeScript uses labels as function parameters if a rest parameter has a tuple type:

function f1(...args: [str: string, num: number]) {}
  // function f1(str: string, num: number): void
function f2(...args: [string, number]) {}
  // function f2(args_0: string, args_1: number): void

Thanks to labels, tuples become a better alternative to overloading, because autocompletion can show parameter names:

// Overloading with tuples
function f1(
  ...args:
    | [str: string, num: number]
    | [num: number]
    | [bool: boolean]
) {}

// Traditional overloading

Use case: preserving argument names when transforming functions  

How that works is demonstrated when we handle partial application later in this post.

Types for tuples  

Tuples and --noUncheckedIndexedAccess  

If we switch on the tsconfig.json option noUncheckedIndexedAccess then TypeScript is more honest about what it knows about an indexable type.

With an Array, TypeScript never knows at compile time at which indices there are elements – which is why undefined is always a possible result with indexed reading:

const arr: Array<string> = ['a', 'b', 'c'];
const arrayElement = arr[1];
expectType<string | undefined>(arrayElement);

With a tuple, TypeScript knows the whole shape and can provide better types for indexed reading:

const tuple: [string, string, string] = ['a', 'b', 'c'];
const tupleElement = tuple[1];
expectType<string>(tupleElement);

Forcing Array literals to be inferred as tuples  

By default, a JavaScript Array literal has an Array type:

// Array
const value1 = ['a', 1];
expectType<
  (string | number)[]
>(value1);

The most common way of changing that is via an as const annotation:

// Tuple
const value2 = ['a', 1] as const;
expectType<
  readonly ["a", 1]
>(value2);

But we can also use satisfies:

// Non-empty tuple
const value3 = ['a', 1] satisfies [unknown, ...unknown[]];
expectType<
  [string, number]
>(value3);

// Tuple (possibly empty)
const value4 = ['a', 1] satisfies [unknown?, ...unknown[]];
expectType<
  [string, number]
>(value4);

Note that as const also narrows the element types to 'a' and 1. With satisfies, they are string and number – unless we use as const for the elements:

// Tuple
const value5 = ['a' as const, 1 as const]
  satisfies [unknown?, ...unknown[]];
expectType<
  ["a", 1]
>(value5);

If we omit the tuple element before the rest element (at the end), we are back to an Array type:

// Array
const value6 = ['a', 1] satisfies [...unknown[]];
expectType<
  (string | number)[]
>(value6);

There is one other type we can use for tuples:

// Tuple
const value7 = ['a', 1] satisfies unknown[] | [];
expectType<
  [string, number]
>(value7);

Using readonly to accept const tuples  

If a type T is constrained to a normal array type then it doesn’t match the type of an as const literal:

type Tuple<T extends Array<unknown>> = T;
const arr = ['a', 'b'] as const;
// @ts-expect-error: Type 'readonly ["a", "b"]' does not satisfy
// the constraint 'unknown[]'.
type _ = Tuple<typeof arr>;

We can change that by switching to a ReadonlyArray:

type Tuple<T extends ReadonlyArray<unknown>> = T;
const arr = ['a', 'b'] as const;
type Result = Tuple<typeof arr>;
expectType<TypeEqual<
  Result, readonly ["a", "b"]
>>(true);

The following two notations are equivalent:

ReadonlyArray<unknown>
readonly unknown[]

In this post, I don’t always make array types readonly because it adds visual clutter.

Enforcing a fixed Array length  

We can use the following trick to enforce a fixed length for Array literals:

function join3<T extends string[] & {length: 3}>(...strs: T) {
  return strs.join('');
}
join3('a', 'b', 'c'); // OK

// @ts-expect-error: Argument of type '["a", "b"]' is not assignable
// to parameter of type 'string[] & { length: 3; }'.
join3('a', 'b');

The caveat is that this technique does not work if the strs come from a variable whose type is an Array:

const arr = ['a', 'b', 'c'];
// @ts-expect-error: Argument of type 'string[]' is not assignable
// to parameter of type 'string[] & { length: 3; }'.
join3(...arr);

In contrast, a tuple works:

const tuple = ['a', 'b', 'c'] as const;
join3(...tuple);

Extracting union types from tuples  

Applying the indexed access operator T[K] to objects  

TypeScript has a built-in keyof operator. We can use the indexed access operator to implement a utility type Valueof:

const englishToGerman = {
  yes: 'ja',
  no: 'nein',
} as const;

type Valueof<Obj> = Obj[keyof Obj]; // (A)
type Values = Valueof<typeof englishToGerman>;
expectType<TypeEqual<
  Values, "ja" | "nein"
>>(true);

In line A, the key type inside the square brackets must be specific: Using, e.g., string or string | number | symbol won’t work – TypeScript will complain.

Applying the indexed access operator T[K] to tuples  

If we apply the indexed access operator to a tuple, we get the tuple elements as a union:

const flowers = ['rose', 'sunflower', 'lavender'] as const;

type Elementof<Tup extends readonly unknown[]> = Tup[number]; // (A)
type Elements = Elementof<typeof flowers>;
expectType<TypeEqual<
  Elements, "rose" | "sunflower" | "lavender"
>>(true);

In this case, TypeScript does let us use the unspecific type number as an operand for the indexed access operator (line A). Which is good because keyof doesn’t work well for tuples:

type Tup = ['a', 'b'];
expectType<TypeEqual<
  // Keys of `Tup` whose names start with `l`
  Extract<keyof Tup, `l${string}`>,
  "length" | "lastIndexOf"
>>(true);

Extracting a union from a tuple of tuples  

Sometimes, it makes sense to encode data as a collection of tuples – e.g. when we want to look up a tuple by any of its elements and performance is not as important. In contrast, Maps only support lookup by key well.

For Maps, it’s easy to compute the keys and the values – which we can use to constrain values when looking up data. Can we do the same for a tuple of tuples? We can, if we use the indexed access operator T[K] twice:

const englishSpanishGerman = [
  ['yes', 'sí', 'ja'],
  ['no', 'no', 'nein'],
  ['maybe', 'tal vez', 'vielleicht'],
] as const;

type English = (typeof englishSpanishGerman)[number][0];
expectType<TypeEqual<
  English, "yes" | "no" | "maybe"
>>(true);

type Spanish = (typeof englishSpanishGerman)[number][1];
expectType<TypeEqual<
  Spanish, "sí" | "no" | "tal vez"
>>(true);

Extracting a union from a tuple of objects  

The same approach works for a tuple of objects:

const listCounterStyles = [
  { name: 'upperRoman', regExp: /^[IVXLCDM]+$/ },
  { name: 'lowerRoman', regExp: /^[ivxlcdm]+$/ },
  { name: 'upperLatin', regExp: /^[A-Z]$/ },
  { name: 'lowerLatin', regExp: /^[a-z]$/ },
  { name: 'decimal',    regExp: /^[0-9]+$/ },
] as const satisfies Array<{regExp: RegExp, name: string}>;

type CounterNames = (typeof listCounterStyles)[number]['name'];
expectType<TypeEqual<
  CounterNames,
  | "upperRoman" | "lowerRoman"
  | "upperLatin" | "lowerLatin"
  | "decimal"
>>(true);

Mapping tuples via mapped types  

A mapped type has the following syntax:

type MapOverType<Type> = {
  [Key in keyof Type]: Promise<Type[Key]>
};

The syntax of a mapped type seems to suggest that it only works for objects. However, it works just as well for tuples:

type WrapValues<T> = {
  [Key in keyof T]: Promise<T[Key]>
};
expectType<TypeEqual<
  WrapValues<['a', 'b']>,
  [Promise<"a">, Promise<"b">]
>>(true);

This is another example:

type Unwrap<T> =
  T extends Promise<infer C> ? C : never;
type UnwrapValues<T> = {
  [Key in keyof T]: Unwrap<T[Key]>
};
expectType<TypeEqual<
  UnwrapValues<[Promise<string>, Promise<number>]>,
  [string, number]
>>(true);

Mapping preserves the kind of type (tuple, array, object, etc.)  

The input of a mapped type (tuple, array, object, etc.) determines what the output looks like:

type WrapValues<T> = {
  [Key in keyof T]: Promise<T[Key]>
};

const tuple = ['a', 'b'] as const;
expectType<TypeEqual<
  WrapValues<typeof tuple>,
  readonly [Promise<"a">, Promise<"b">]
>>(true);

const array1 = ['a', 'b'];
expectType<TypeEqual<
  WrapValues<typeof array1>,
  Promise<string>[]
>>(true);

const array2 = ['a' as const, 'b' as const];
expectType<TypeEqual<
  WrapValues<typeof array2>,
  Promise<"a" | "b">[]
>>(true);

const obj1 = {a: 1, b: 2};
expectType<TypeEqual<
  WrapValues<typeof obj1>,
  { a: Promise<number>, b: Promise<number> }
>>(true);

const obj2 = {a: 1, b: 2} as const;
expectType<TypeEqual<
  WrapValues<typeof obj2>,
  { readonly a: Promise<1>, readonly b: Promise<2> }
>>(true);

Mapping preserves the labels of tuple elements  

Mapping also preserves the labels of tuple elements:

type WrapValues<T> = {
  [Key in keyof T]: Promise<T[Key]>
};
expectType<TypeEqual<
  WrapValues<[a: number, b: number]>,
  [a: Promise<number>, b: Promise<number>]
>>(true);

Computing with types  

  • In this section, we’ll explore computing with types via small examples.
  • In the next section, we’ll look at real-world use cases for this kind of computation.

Extracting parts of tuples  

To extract parts of tuples, we use infer.

Extracting the first element of a tuple  

We infer the first element and ignore all other elements by using unknown as a wildcard type that matches anything.

type First<T extends Array<unknown>> =
T extends [infer F, ...unknown[]]
  ? F
  : never
;
expectType<TypeEqual<
  First<['a', 'b', 'c']>,
  'a'
>>(true);

Extracting the last element of a tuple  

The approach we used to extract the first element (in the previous example) also works for extracting the last element:

type Last<T extends Array<unknown>> =
T extends [...unknown[], infer L]
  ? L
  : never
;
expectType<TypeEqual<
  Last<['a', 'b', 'c']>,
  'c'
>>(true);

Extracting the rest of a tuple (elements after the first one)  

To extract the rest of a tuple (the elements after the first one), we make use the wildcard type unknown for the first element and infer what is spread after it:

type Rest<T extends Array<unknown>> =
T extends [unknown, ...infer R]
  ? R
  : never
;
expectType<TypeEqual<
  Rest<['a', 'b', 'c']>,
  ['b', 'c']
>>(true);

Concatenating tuples  

To concatenate two tuples T1 and T2, we spread them both:

type Concat<T1 extends Array<unknown>, T2 extends Array<unknown>> =
  [...T1, ...T2]
;
expectType<TypeEqual<
  Concat<['a', 'b'], ['c', 'd']>,
  ['a', 'b', 'c', 'd']
>>(true);

Recursion over tuples  

To explore recursion over tuples, let’s implement wrapping tuple elements with recursion (where we previously used a mapped type):

Recursing over tuples in TypeScript

type WrapValues<Tup> =
  Tup extends [infer First, ...infer Rest] // (A)
    ? [Promise<First>, ...WrapValues<Rest>] // (B)
    : [] // (C)
;
expectType<TypeEqual<
  WrapValues<['a', 'b', 'c']>,
  [Promise<'a'>, Promise<'b'>, Promise<'c'>]
>>(true);

We use a technique that is inspired by how functional programming languages recurse over lists:

  • Line A: We check whether we can split Tup into the first element First and the remaining elements Rest.

  • Line B: If yes then Tup has at least one element. We return a tuple whose first element is the wrapped First and whose remaining elements are computed by a self-recursive call.

  • Line C: If no then Tup is empty. We return an empty tuple.

In functional programming, First is often called Head and Rest is often called Tail.

Flattening a tuple of tuples  

Let’s use recursion to flatten a tuple of tuples:

type Flatten<Tups extends Array<Array<unknown>>> =
  Tups extends [
    infer Tup extends Array<unknown>, // (A)
    ...infer Rest extends Array<Array<unknown>> // (B)
  ]
    ? [...Tup, ...Flatten<Rest>]
    : []
;
expectType<TypeEqual<
  Flatten<[['a', 'b'], ['c', 'd'], ['e']]>,
  ['a', 'b', 'c', 'd', 'e']
>>(true);

In this case, the inferred types Tup and Rest are more complex – which is why TypeScript complains if we don’t use extends (line A, line B) to constrain them.

Filtering a tuple  

The following code uses recursion to filter out empty strings in a tuple:

type RemoveEmptyStrings<T extends Array<string>> =
  T extends [
    infer First extends string,
    ...infer Rest extends Array<string>
  ]
    ? First extends ''
      ? RemoveEmptyStrings<Rest>
      : [First, ...RemoveEmptyStrings<Rest>]
    : []
;
expectType<TypeEqual<
  RemoveEmptyStrings<['', 'a', '', 'b', '']>,
  ["a", "b"]
>>(true);

Creating a tuple with a given length  

If we want to create a tuple that has a given length Len, we are faced with a challenge: How do we know when to stop? We can’t decrement Len, we can only check if it is equal to a given value (line A):

type Repeat<
  Len extends number, Value,
  Acc extends Array<unknown> = []
> = 
  Acc['length'] extends Len // (A)
    ? Acc // (B)
    : Repeat<Len, Value, [...Acc, Value]> // (C)
;
expectType<TypeEqual<
  Repeat<3, '*'>,
  ["*", "*", "*"]
>>(true);
expectType<TypeEqual<
  Repeat<3, string>,
  [string, string, string]
>>(true);
expectType<TypeEqual<
  Repeat<3, unknown>,
  [unknown, unknown, unknown]
>>(true);

How does this code work? We use another functional programming technique and introduce an internal accumulator parameter Acc:

  • While recursion is still ongoing, we assemble the eventual result in Acc (line C).
  • One the length of Acc is equal to Len (line A), we are done and can return Acc (line B).

Computing a range of numbers  

We can use the same technique to compute a range of numbers. Only this time, we append the current length of the accumulator to the accumulator:

type NumRange<Upper extends number, Acc extends number[] = []> =
  Upper extends Acc['length']
    ? Acc
    : NumRange<Upper, [...Acc, Acc['length']]>
;
expectType<TypeEqual<
  NumRange<3>,
  [0, 1, 2]
>>(true);

Dropping initial elements  

This is one way of implementing a utility type that removes the first Num elements of a Tuple:

type Drop<
  Tuple extends Array<unknown>,
  Num extends number,
  Counter extends Array<boolean> = []
> =
  Counter['length'] extends Num
    ? Tuple
    : Tuple extends [unknown, ...infer Rest extends Array<unknown>]
      ? Drop<Rest, Num, [true, ...Counter]>
      : Tuple
;
expectType<TypeEqual<
  Drop<['a', 'b', 'c'], 2>,
  ["c"]
>>(true);

This time, we use the accumulator variable Counter to count up – until Counter['length'] is equal to Num.

We can also use inference (idea by Heribert Schütz):

type Drop<
  Tuple extends Array<unknown>,
  Num extends number
> =
  Tuple extends [...Repeat<Num, unknown>, ...infer Rest]
    ? Rest
    : never
;

We use the utility type Repeat to compute a tuple where each element is the wildcard type unknown that matches any type. Then we match Tuple against a tuple pattern that begins with those elements. The remaining elements are the result we are looking for and we extract it via infer.

Real-world examples  

Partial application that preserves parameter names  

Let’s implement the function applyPartial(func, args) for partially applying a function func. It works similarly to the function method .bind():

function applyPartial<
  Func extends (...args: any[]) => any,
  InitialArgs extends unknown[],
>(func: Func, ...initialArgs: InitialArgs) {
  return (...remainingArgs: RemainingArgs<Func, InitialArgs>)
  : ReturnType<Func> => {
    return func(...initialArgs, ...remainingArgs);
  };
}

//----- Test -----

function add(x: number, y: number): number {
  return x + y;
}
const add3 = applyPartial(add, 3);
expectType<TypeEqual<
  typeof add3,
  // The parameter name is preserved!
  (y: number) => number
>>(true);

We return a partially applied func. To compute the type for the parameter remainingArgs, we remove the InitialArgs from the arguments of Func – via the following utility type:

type RemainingArgs<
  Func extends (...args: any[]) => any,
  InitialArgs extends unknown[],
> =
  Func extends (
    ...args: [...InitialArgs,
    ...infer TrailingArgs]
  ) => unknown
    ? TrailingArgs
    : never
;

//----- Test -----

expectType<TypeEqual<
  RemainingArgs<typeof add, [number]>,
  [y: number]
>>(true);

Typing a function zip()  

Consider a zip() function that converts a tuple of iterables to an iterable of tuples (source code of an implementation):

> zip([[1, 2, 3], ['a', 'b', 'c']])
[ [1, 'a'], [2, 'b'], [3, 'c'] ]

The following utility type Zip computes a return type for it:

type Zip<Tuple extends Array<Iterable<unknown>>> =
  Iterable<
    { [Key in keyof Tuple]: UnwrapIterable<Tuple[Key]> }
  >
;
type UnwrapIterable<Iter> =
  Iter extends Iterable<infer T>
    ? T
    : never
;

expectType<TypeEqual<
  Zip<[Iterable<string>, Iterable<number>]>,
  Iterable<[string, number]>
>>(true);

Typing a function zipObj()  

Function zipObj() is similar to zip(): It converts an object of iterables to an iterable of objects (source code of an implementation):

> zipObj({num: [1, 2, 3], str: ['a', 'b', 'c']})
[ {num: 1, str: 'a'}, {num: 2, str: 'b'}, {num: 3, str: 'c'} ]

The following utility type ZipObj computes a return type for it:

type ZipObj<Obj extends Record<string, Iterable<unknown>>> =
  Iterable<
    { [Key in keyof Obj]: UnwrapIterable<Obj[Key]> }
  >
;
type UnwrapIterable<Iter> =
  Iter extends Iterable<infer T>
    ? T
    : never
;

expectType<TypeEqual<
  ZipObj<{a: Iterable<string>, b: Iterable<number>}>,
  Iterable<{a: string; b: number}>
>>(true);

util.promisify(): converting a callback-based function to a Promise-based one  

The Node.js function util.promisify(cb) converts a function that returns its result via a callback to a function that returns it via a Promise. Its official type is long:

// 0 arguments
export function promisify<TResult>(
    fn: (callback: (err: any, result: TResult) => void) => void,
): () => Promise<TResult>;
export function promisify(
  fn: (callback: (err?: any) => void) => void
): () => Promise<void>;

// 1 argument
export function promisify<T1, TResult>(
    fn: (arg1: T1, callback: (err: any, result: TResult) => void) => void,
): (arg1: T1) => Promise<TResult>;
export function promisify<T1>(
  fn: (arg1: T1, callback: (err?: any) => void) => void
): (arg1: T1) => Promise<void>;

// 2 arguments
export function promisify<T1, T2, TResult>(
    fn: (arg1: T1, arg2: T2, callback: (err: any, result: TResult) => void) => void,
): (arg1: T1, arg2: T2) => Promise<TResult>;
export function promisify<T1, T2>(
    fn: (arg1: T1, arg2: T2, callback: (err?: any) => void) => void,
): (arg1: T1, arg2: T2) => Promise<void>;

// Etc.: up to 5 arguments

Let’s try to simplify it:

function promisify<Args extends any[], CB extends NodeCallback>(
  fn: (...args: [...Args, CB]) => void,
): (...args: Args) => Promise<ExtractResultType<CB>> {
  // ···
}
type NodeCallback =
  | ((err: any, result: any) => void)
  | ((err: any) => void)
;

//----- Test -----

function nodeFunc(
  arr: Array<string>,
  cb: (err: Error, str: string) => void
) {}
const asyncFunc = promisify(nodeFunc);
expectType<
  (arr: string[]) => Promise<string>
>(asyncFunc);

The previous code uses the following utility type:

type ExtractResultType<F extends NodeCallback> =
  F extends (err: any) => void
  ? void
  : F extends (err: any, result: infer TResult) => void
  ? TResult
  : never
;

//----- Test -----

expectType<TypeEqual<
  ExtractResultType<(err: Error, result: string) => void>,
  string
>>(true);
expectType<TypeEqual<
  ExtractResultType<(err: Error) => void>,
  void
>>(true);

Limitations of computing with tuples  

There are constraints we can’t express via TypeScript’s type system. The following code is one example:

type Same<T> = {a: T, b: T};

function one<T>(obj: Same<T>) {}
one({a: false, b: 'abc'}); // 👍 error

function many<A, B, C, D, E>(
  objs: [Same<A>, Same<B>]
      | [Same<A>, Same<B>, Same<C>]
      | [Same<A>, Same<B>, Same<C>, Same<D>]
      | [Same<A>, Same<B>, Same<C>, Same<D>, Same<E>,
        ...Array<Same<unknown>>]
) {}

many([
  {a: true, b: true},
  {a: 'abc', b: 'abc'},
  {a: 7, b: false} // 👍 error
]);

We’d like to express:

  • Function many() receives an Array of objects.
  • The types of the two properties should be the same.

We can’t loop and introduce one variable per loop iteration. Therefore, we list the most common cases manually.

Sources of this blog post