Using infer to unpack nested types

Inspecting a type with a magnifying glass

In a previous post I suggested using intersections as a sort of type-level "as any". I'd personally found this technique useful on a few projects as a way of silencing type errors without losing type safety. Because you often needed to apply this trick two or three times for deeply nested types, it also led me to explore ways of reducing repetition in type-level code.

But when I started reworking that advice into an Item for the upcoming second edition of Effective TypeScript, I learned something interesting: there's usually a better way! TypeScript's infer keyword can infer quite a lot, and it offers a more direct way to achieve the same goals I talked about in those two posts without the repetition.

Those previous posts were motivated by my crosswalk library, which helps you build and use type-safe REST APIs. Using infer works in that context, too, but the difference is even more dramatic when we look at an OpenAPI Schema.

Say your API lets you create a user. (It also lets you GET a user, but I'll only show the POST here since this is already verbose.) The OpenAPI Schema might look like this:

{
"openapi": "3.0.3",
"info": { "title": "Users API", "version": "0.1" },
"paths": {
"/users": {
"post": {
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateUserRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Newly-created User",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/User"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"CreateUserRequest": {
"type": "object",
"properties": {
"name": { "type": "string" },
"age": { "type": "number" }
},
"required": [ "name", "age" ]
},
"User": {
"type": "object",
"properties": {
"id": { "type": "string" },
"name": { "type": "string" },
"age": { "type": "number" }
},
"required": [ "id", "name", "age" ]
}
}
}
}

You can run this through openapi-typescript to generate TypeScript types:

/** This file was auto-generated by openapi-typescript. */

export interface paths {
"/users": {
post: {
requestBody: {
content: {
"application/json": components["schemas"]["CreateUserRequest"];
};
};
responses: {
/** @description Newly-created User */
200: {
content: {
"application/json": components["schemas"]["User"];
};
};
};
};
// also get, etc.
};
}

export interface components {
schemas: {
CreateUserRequest: {
name: string;
age: number;
};
User: {
id: string;
name: string;
age: number;
};
};
}

Accessing these types via the path structure involves a whole bunch of indexing:

type UserResponse = paths["/users"]["post"]["responses"][200]["content"]["application/json"];

If you want to make a generic POST method, you'll quickly run into some errors:

declare function post<Path extends keyof paths>(
endpoint: Path
): Promise<paths[Path]["post"]["responses"][200]["content"]["application/json"]>;
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Type '"application/json"' cannot be used to index type 'paths[Path]["post"]["responses"][200]["content"]'. (2536)

This error makes sense: TypeScript has no reason to believe that this deep sequence of index operations will work on an arbitrary type.

You can use infer to extract just what you want out of this structure:

type HttpVerb = 'get' | 'post' | 'patch' | 'delete' | 'update';
type ResponseForMethod<Path extends keyof paths, Verb extends HttpVerb> =
paths[Path] extends Record<Verb, {
responses: {
200: {
content: {
'application/json': infer ResponseType, // <-- the "infer"
}
}
}
}> ? ResponseType : never;

declare function post<Path extends keyof paths>(
endpoint: Path
): Promise<ResponseForMethod<Path, 'post'>>;

const response = post('/users');
// ^? const response: Promise<{ id: string; name: string; age: number; }>

What surprised (and impressed me) was that infer can see right through the Record helper, which is a wrapper around a mapped type.

If the endpoint doesn't support POST requests, you'll get a never type back. A type error would be better, but hopefully the never will be enough to alert the caller that something is amiss.

My previous posts would have suggested this chain of helpers instead:

type LooseKey<T, K> = T[K & keyof T];
type LooseKey2<T, K1, K2> = LooseKey<LooseKey<T, K1>, K2>;
type LooseKey3<T, K1, K2, K3> = LooseKey<LooseKey2<T, K1, K2>, K3>;
type LooseKey4<T, K1, K2, K3, K4> = LooseKey<LooseKey3<T, K1, K2, K3>, K4>;
type LooseKey5<T, K1, K2, K3, K4, K5> = LooseKey<LooseKey4<T, K1, K2, K3, K4>, K5>;

type ResponseForMethod<Path extends keyof paths, Verb extends HttpVerb> =
LooseKey5<paths[Path], Verb, 'responses', 200, 'content', 'application/json'>;

Yikes! Compared to this intersection form, the infer approach is more direct, easier to read and more closely matches the layout of the data. And if you need to extract multiple pieces of information from a type, it's clear how to do it. This is a nice win.

So is "intersect what you have with whatever TypeScript wants" still a useful technique? You sometimes need to write & string when passing a type parameter to another generic type that expects a string:

// see previous post
type ExtractRouteParams<Path extends string> = ...;
// e.g. ExtractRouteParams<'/users/:userId'> = {userId: string}

class ApiWrapper<API> {
apiGet<Path extends keyof API>(
path: Path,
queryParams: ExtractRouteParams<Path>,
// ~~~~
// Type 'Path' does not satisfy the constraint 'string'.
// Type 'keyof API' is not assignable to type 'string'.
// Type 'string | number | symbol' is not assignable to type 'string'.
// Type 'number' is not assignable to type 'string'. (2344)
): Promise<GetResponseForMethod<API, Path>>;
}

Check out TypeScript Splits the Atom for a definition of ExtractRouteParams. The problem is that we expect keyof API to be a subtype of string, but strictly speaking all TypeScript knows is that it's a subtype of PropertyKey, aka string | number | symbol. There's nothing preventing API from being string[], for example, in which case keyof API = number. Since ExtractRouteParams expects a string, this won't fly.

The best solution would be to tell TypeScript that API should only have string keys. But I'm not aware of any way to do that: writing ApiWrapper<API extends Record<string, any>> results in the same error.

In this case, using an intersection to silence the error still makes sense:

class ApiWrapper<API> {
apiGet<Path extends keyof API>(
path: Path,
queryParams: ExtractRouteParams<Path & string>, // ok
): Promise<GetResponseForMethod<API, Path>>;
}

When you're working with nested object types, remember that you can use infer to extract a particular type from deep inside them.

Image credit: Wikimedia Commons

Like this post? Consider subscribing to my newsletter, the RSS feed, or following me on Twitter.
Effective TypeScript Book Cover

Effective TypeScript shows you not just how to use TypeScript but how to use it well. The book's 62 items help you build mental models of how TypeScript and its ecosystem work, make you aware of pitfalls and traps to avoid, and guide you toward using TypeScript’s many capabilities in the most effective ways possible. Regardless of your level of TypeScript experience, you can learn something from this book.

After reading Effective TypeScript, your relationship with the type system will be the most productive it's ever been! Learn more »