Featured image

Introduction Link to heading

Reverse mapped types are a powerful yet little-known feature of TypeScript that allow us to “run mapped types backward”. They are mainly a mechanism for inferring a function’s type parameters from values; however, the same inference steps can be performed at the type level using the infer keyword.

The purpose of this article is to serve as a comprehensive guide to reverse mapped types, explaining what they are and how they can be used to set interesting constraints on the values of a type and to provide useful context sensitive information. Various references to the compiler source code will be made in order to provide a deeper understanding of the topic.

 

What are reverse mapped types? Link to heading

Let’s take a simple generic function like the following:

function foo<T>(a: ReadonlyArray<T>): ReadonlyArray<T> {
    return [...a]
}

We are not surprised that TypeScript can infer the type T of the array elements from the argument passed to the function:

foo([1, 2, 3]); // T = number

foo(['a', 'b', 'c']); // T = string

But what if we had a mapped type in place of ReadonlyArray<T>? Would TypeScript still be able to infer the type T? This is what we are referring to when we talk about inverting a mapped type.

An introductory example Link to heading

Let’s kick things off with a simple example. Suppose we have the following type definitions:

type Box<T> = { 
    value: T
}

type Unboxify<T extends Record<string, Box<any>>> = {
    [K in keyof T]: T[K]["value"];
}

Let’s write a function that takes a Record<string, Box<any>> and unwraps the value properties:

function unwrap<T extends Record<string, Box<any>>>(record: T): Unboxify<T> {
    const result = {} as Unboxify<T>
    for (const key in record) {
        result[key] = record[key].value
    }
    return result
}

What return type would we expect from the following function call?

unwrap({
    a: { value: "hi there" },
    b: { value: 42 }
})

We would expect { a: string, b: number }, of course!

Reverse mapped types give us a different perspective on this kind of problems. We can simplify this situation by inverting the relationship between the return type and the parameter type: instead of manually deriving the return type from the parametric record type, we can work backward from the type of the actual parameter, letting the return type be the goal of the inference process.

type Box<T> = { 
    value: T
}

type BoxedRecord<T> = {
    [K in keyof T]: Box<T[K]>
}

// note that the above mapped type is used in input position here
function unwrap<T>(record: BoxedRecord<T>): T {
    const result = {} as T
    for (const key in record) {
        result[key] = record[key].value
    }
    return result
}

unwrap({
    a: { value: "hi there" },
    b: { value: 42 }
})

The returned type is { a: string, b: number } again because TypeScript is able to infer the type T from the argument passed to the function, but that’s not a given at all!

Why?

To do something like this, the compiler was able to answer the following question: for which type T do we have that BoxedRecord<T> is { a: { value: string }, b: { value: number } }? We can mentally reverse the action of the mapped type: a: { value: string } implies that T[K] must be string when K is 'a', similarly b: { value: number } implies that T[K] must be number when K is 'b'. The fact that TypeScript is able to achieve this too is simply amazing!

As I mentioned before this works at the type level too, but the focus of this article is on the function’s type parameters inference. TypeScript is able to do this inversion for us in some cases, and in this article we will explore the potential, and the limits, of this feature.

 

How does it work? Link to heading

The outline of the situation is more or less the following:

type MappedType<T> = {
    [K in keyof T]: F<T[K]>
}


declare function foo<T extends C>(mt: MappedType<T>): ...
foo(x) // T inferred from x

where the template F<T[K]> means that T[K] is used in some way there. This is very important because it’s gonna be used as inference site for the types of the keys while the compiler is trying to invert the action of the mapped type. In the template you can also use K alone to enforce particular constraints or to get context sensitive information, but this is not enough to infer the types of the keys.

In broad terms, what happens is the following:

  1. If not already known, TypeScript infers the type of the argument x passed to the function foo. This type is internally named as source.

  2. TypeScript does its best to invert the action of the MappedType mapped type starting from the source type to determine what T is. In particular, each key of T will be inferred independently from the others by exploiting the template F<T[K]>. Each standalone inference is not special in itself; for each key K of the source object, T[K] becomes the inference target, and its inference is scoped to that particular key. However, since the type inference for each key is separate from the others, it’s like having defined a variable length list of type parameters, one for each key of T. If the inference of a single key fails, the resulting type for that key will be unknown, while the other keys will not be affected by the failure.
    Be aware of the fact that, as for now, TypeScript does not resort to the constraint type before falling back to unknown in such cases.

  3. TypeScript checks that the just inferred type T is indeed assignable to its constraint C. If that is not the case, T will become the constraint itself, discarding whatever was inferred before. This is the default behaviour of the getInferredType internal function and it applies to any function call, but it could lead to some unexpected results in this situation.

  4. TypeScript now applies the mapped type MappedType to whatever T has become at this point, to determine the type of the formal parameter mt.

  5. TypeScript checks if the type of the argument x is assignable to the type of the formal parameter mt, erroring if that is not the case.

 

The source’s requirements Link to heading

Which are the requirements that the source type must satisfy in order to be reverse mappable? A couple of comments in the TypeScript source code give us the answer:

We consider a source type reverse mappable if it has a string index signature or if it has one or more properties and is of a partially inferable type.

We consider a type to be partially inferable if it isn’t marked non-inferable or if it is an object literal type with at least one property of an inferable type. For example, an object literal { a: 123, b: x => true } is marked non-inferable because it contains a context sensitive arrow function, but is considered partially inferable because property 'a' has an inferable type.

The fact that partially inferable types are allowed is very important, because this means that reverse mapped types are able to provide context sensitive information back to the source type:

type ContextSensitive<T> = {
    [K in keyof T]: { v: T[K], f: (_: T[K]) => void }
}

declare function useCS<T>(cs: ContextSensitive<T>): void


useCS({
  num: { v: 42, f: (n) => n * 10 },
                 // ^? n: number
  str: { v: "hi there", f: (s) => s.repeat(3) }
                         // ^? s: string
})

Playground.

In the example above we use T[K] both as type for the property 'v' and as type for the parameter of the function 'f'. TypeScript is able to infer that T is { num: number, str: string } from the partially inferable argument passed to useCS by using the type of the property 'v' only. When the fifth of the previous points comes into play, some context sensitive information is provided back from ContextSensitive<{ num: number, str: string }> to the source: the type of the parameters of the functions 'f'.

 

The mapped type’s requirements Link to heading

What about the requirements that the mapped type must satisfy? As for now, the inferFromObjectTypes internal function set an interesting one:

if (getObjectFlags(target) & ObjectFlags.Mapped && !(target as MappedType).declaration.nameType) {
    const constraintType = getConstraintTypeFromMappedType(target as MappedType);
    if (inferToMappedType(source, target as MappedType, constraintType)) {
        return;
    }
}

The point is that there should not be any nameType, and that means no as clause in the mapped type. This is a limitation that could be removed in the future, but for now it is what it is.

Let’s dig into the inferToMappedType internal function now. From the code we see that TypeScript is able to reverse four kinds of mapped types:

  1. homomorphic mapped types like { [P in keyof T]: X }
  2. mapped types like { [P in K]: X }, where the constraint K is a type parameter
  3. mapped types like { [P in A | B]: X } where the constraint is an union, useful when the union contains a constraint similar to the one in 1 or 2
  4. mapped types like { [P in A & B]: X } where the costraint is an intersection, useful when the intersection contains a constraint similar to the one in 1 or 2

We will explore how the union constraint ensures the presence of certain properties, while the intersection constraint prevents the presence of additional properties.

Let’s dig into each of these cases.

Homomorphic mapped types Link to heading

I wrote about homomorphic mapped types in a previous article, so take a look if you’re unfamiliar with them.

The source code says:

We’re inferring from some source type S to a homomorphic mapped type { [P in keyof T]: X }, where T is a type variable. Use inferTypeForHomomorphicMappedType to infer a suitable source type and then make a secondary inference from that type to T.

The reason behind the double inference pass is related to the priority of some inferences, but I have to admit this is a bit obscure to me. Feel free to take a look at the source code if you’re interested in this and let me know what you find out! The main point, however, is that TypeScript should be able to reverse them, as long as there is no as clause. Pun not intended.

Mapped type with a type parameter as constraint Link to heading

This is a very interesting case. Suppose we have the following mapped type:

type MappedType<K extends PropertyKey> = {
    [P in K]: number
}

declare function useMT<K extends PropertyKey>(mt: MappedType<K>): K

foo({
  a: 42,
  b: 1234
})

Playground.

We have that K gets successfully inferred as 'a' | 'b'. How? The source code answers this question:

We’re inferring from some source type S to a mapped type { [P in K]: X }, where K is a type parameter. First infer from keyof S to K.

That’s exactly what TypeScript did: it inferred from keyof { a: number, b: number }, that is 'a' | 'b', to K.

But TypeScript’s capabilities don’t stop here. Suppose we have the following mapped type that resembles the Pick one:

// a custom version of the built-in Pick type
type MyPick<T, K extends keyof T> = {
  [P in K]: { value: T[P] };
}

// a function that takes a MyPick<T, K> and returns a Pick<T, SK>
// where SK is a subset of K
declare function unpick<
  T,
  K extends keyof T,
  SK extends K,
>(t: MyPick<T, K>, keys: SK[]): Pick<T, SK>;

unpick({
  a: { value: 42 },
  b: { value: false },
}, ["a"]);

Playground.

We have that T gets inferred as { a: number, b: boolean } and K gets inferred as 'a' | 'b'. As before, K is inferred from keyof { a: number, b: boolean }, that is 'a' | 'b'. But what about T? Let’s again refer to the source code:

If K (the one in { [P in K]: X }) is constrained to a type C, also infer to C. Thus, for a mapped type { [P in K]: X }, where K extends keyof T, we make the same inferences as for a homomorphic mapped type { [P in keyof T]: X }.

We see indeed that inferToMappedType is called recursively in this case:

const extendedConstraint = getConstraintOfType(constraintType);
if (extendedConstraint && inferToMappedType(source, target, extendedConstraint)) {
    return true;
}

If no inferences can be made to K’s constraint, TypeScript will infer from a union of the property types in the source to the template type X. The following example shows this:

type MappedType<K extends PropertyKey, X> = {
    [P in K]: X
}

declare function useMT<K extends PropertyKey, X>(mt: MappedType<K, X>): [K, X]

useMT({
  a: ["a", "a-prop"],
  b: false
})

Playground.

We have that K gets inferred as 'a' | 'b' as before, wherease X gets inferred as boolean | string[].

Union as constraint Link to heading

Let’s consider the following mapped type:

type MappedType<T> = {
  [K in keyof T | "mustBePresent"]: {
    // cannot put just T[K] here, because K cannot be used to index T
    // i.e. there is no guarantee here that "mustBePresent" is a key of T
    value: K extends "mustBePresent" ? unknown : K extends keyof T ? T[K] : never;
  };
};

declare function unmap<T>(t: MappedType<T>): T;

const res = unmap({
  a: { value: "andrea" },
  b: { value: "simone" },
  c: { value: "costa" },
  mustBePresent: { value: 123 },
});

Playground.

We have that T gets inferred as { a: string, c: string, c: string, mustBePresent: number }. In few words, TypeScript loops through the union’s entries, finds the keyof T and reverses the whole source type as we saw before.

This example shows us that reverse mapped types could be useful not only because of their reversing capabilities, but also because they can enforce some properties in the source type. Had we omitted the 'mustBePresent' field, TypeScript would have inferred T as { a: string, b: string, c: string }, but when MappedType is applied to it to get the type of the formal parameter t, the resulting type would have been { a: { value: string }, c: { value: string }, x: { value: string }, mustBePresent: { value: unknown } }. This would have caused an error, because the source type would not have been assignable to the formal parameter type: Property ‘mustBePresent’ is missing in type ‘{ a: { value: string; }; b: { value: string; }; c: { value: string; }; }’ but required in type ‘MappedType<{ a: string; b: string; c: string; }>’.

Intersection as constraint Link to heading

From the version 5.4 of the compiler, TypeScript is able to reverse mapped types with an intersection constraint. This is a very interesting feature, because it allows us to prevents the presence of additional properties while inferring a type. In other words, this provides us with the ability to enable EPC (Excess Property Checking) on type parameters inference sites.

Let’s consider the following example:

interface Foo {
  bar: number;
  baz: string;
  record: Record<any, any>;
}

type MappedType<T> = {
  // the intersection constraint on K is keyof T & keyof Foo
  [K in keyof T & keyof Foo]: T[K]
}

declare function useFoo<T extends Foo>(foo: MappedType<T> ): T

useFoo({ 
  bar: 1,
  baz: 'a',
  record: { a: 1, b: 2 },
  extra: 123, // <-- EPC error
})

Playground.

TypeScript loops through the intersection’s entries, finds the keyof T and reverses the whole source type as we saw before. The intersection constraint ensures that the source type has only the properties that are present in Foo, and this is why the presence of the 'extra' property causes an excess property error. It’s worth noting that the 'extra' property is stripped away from the inferred T because it wouldn’t survive the action of the mapped type anyway.

In this way you get both inference and EPC!

 

Arrays and tuples Link to heading

We said that reverse mapping gets applied to the type of each property of an object type independently from the others. What if we have an array or a tuple? A comment inside the createReverseMappedType internal function says that:

For arrays and tuples we infer new arrays and tuples where the reverse mapping has been applied to the element type(s).

One example to rule them all:

// just removes the 'on' prefix from the event names
type PossibleEventType<K> = K extends `on${infer Type}` ? Type : never;

type TypeListener<T extends ReadonlyArray<string>> = {
  [I in keyof T]: {
    type: T[I];
    listener: (ev: T[I]) => void;
  };
};

declare function bindAll<
  T extends HTMLElement,
  Types extends ReadonlyArray<PossibleEventType<keyof T>>
>(target: T, listeners: TypeListener<Types>): void;

// {} as a fake HTMLInputElement
bindAll({} as HTMLInputElement, [
  {
    type: "blur",
    listener: (ev) => {
            // ^? ev: "blur"
    }
  },
  {
    type: "click",
    listener: (ev) => {
            // ^? ev: "click"
    }
  },
]);

Playground.

Here we see that TypeScript is able to properly infer Types as the tuple type ["blur", "click"] by reverting the type of the input array with respect to the TypeListener mapped type. TypeScript will then apply the TypeListener mapped type to it to determine the type of the formal parameter listeners, and that provides the partially inferable source type with the context sensitive information it needs to get the type of the ev parameters in the callbacks.

The inferred Types must satisfy its constraint too, i.e. it must be an array or a tuple of strings containing some event names belonging to the input HTMLElement, without the 'on' prefix.

 

Enforcing recursive constraints on the source type Link to heading

We saw that reverse mapped types can be used to enforce some constraints on the source type. The following example, borrowed from Mateusz Burzyński, builds on this concept and shows that recursion may be allowed:

type StateConfig<T> = {
  initial?: keyof T;
  states?: {
    // here is the mapped type
    [K in keyof T]: StateConfig<T[K]> & {
      on?: Record<string, keyof T>;
    };
  };
};

declare function createMachine<T>(config: StateConfig<T>): T;

createMachine({
  initial: "a",
  states: {
    // try to get rid of "a"
    a: {
      on: {
        // try to change "a" to something else
        NEXT: "a",
      },
    },
    b: {
      initial: "nested",
      on: {
        NEXT: "b",
      },
      states: {
        nested: {
          on: {
            TEST: "nested",
          },
        },
      },
    },
  },
});

Playground.

The inferred T type is a little bit ugly but it’s correct: { a: unknown, b: { nested: unknown } }. Why are there those unknown? When any of the sub-fields of an arbitrary nested 'states' field does not contain a StateConfig, there will be no candidate for T[K] and so TypeScript will resort to unknown for that sub-field.

Which constraints are we enforcing on the source type? At any level, the 'initial' field must be a key of the object present in the 'states' field at the same level. Furthermore, we can jump from one state to another only if both are defined at the same level.

 

Further limitations Link to heading

It follows a list of limitations, by no means complete, that you should be aware of when using reverse mapped types, which I’m not discussing in detail here:

 

Conclusion Link to heading

The very first time I’ve heard about reverse mapped types was on Xitter a couple of years ago, thanks to the already mentioned Mateusz, whom I thank for the countless insights he gave me on the topic and for his extensive review of this article. TypeScript has the bad habit of having a lot of super useful and super interesting but undocumented advanced features and this is one of them. It’s not a coincidence that my primary reference for writing this article has been the compiler’s source code itself.

The only other resource on the topic that I can suggest is a talk by Mateusz at TypeScript Congress 2023, titled Infer multiple things at once with reverse mapped types.

I want to thank David Blass too, who took the time to review the article and gave me some very useful feedback to clarify some points.

I hope that this article has been useful to you and that you have learned something new. If you have any questions or comments, feel free to reach out to me on Xitter.