Leveraging extends to infer return types

In many statically typed languages, once we accept a generalised interface as a parameter we lose the ability to retrieve concrete values from our return type. Not so in TypeScript.

Published
Last updated
Photo of Jay in Bazel

I’m a software engineer living and working in East London. I’m currently helping to build a one-stop-shop for the digitisation of alternative assets over at Daphne. Although once strictly front-end, today I work across the whole stack, including dipping my toes into DevOps and writing  Rust & Go.

DALL·E generated illustration of several abstract shapes merging together.

Setting the scene

Imagine you have a collection of form Field classes. Each class shares a similar interface, but returns different concrete values for display data:

Logo for TypeScriptTypeScript
// Generic interface interface Field { // Returns at least a `type` field, but also other unknown metadata. displayData(): { type: string; [x: string]: unknown; }; } // 👇 Concrete field types. // Imagine these are populated in their constructors. class TextInput implements Field { displayData() { return { type: 'text' as const, required: true, maxLength: 20, }; } } class CheckboxGroup implements Field { displayData() { return { type: 'checkbox' as const, options: [ { name: 'fruit', value: 'apple', checked: false }, { name: 'fruit', value: 'bananna', checked: false } ], }; } }

If we wanted to aggregate display data for a set of fields, we could write:

Logo for TypeScriptTypeScript
function displayData(...fields: Field[]) { return fields.map(f => f.displayData()); } const result = displayData(new TextInput(), new CheckboxGroup());

Unfortunately, by doing so we’ve narrowed the return type to be a collection of the Field interface’s displayData value, giving us the following:

Logo for TypeScriptTypeScript
type Result = { type: string }[];

Even though we know that each entry contains additional metadata, we’ve lost the original type information. If we wanted to retrieve it, we’d have to write a type guard or explicitly cast each entry.

Discriminated unions to the rescue

If we can be sure that we know all the types of Field ahead of time, we could write:

Logo for TypeScriptTypeScript
type AllFields = TextInput | CheckboxGroup; function displayDataUnion(...fields: AllFields[]) { return fields.map(f => f.displayData()); }

This gives us an accurate final return type, including all possible metadata fields:

Logo for TypeScriptTypeScript
type Result = ( | { type: "text"; required: boolean; maxLength: number; } | { type: "checkbox"; options: { name: string; value: string; checked: boolean }[] } )[];

But what if we don’t know all the possible Field classes ahead of time? If we’re creating a plugin-style API, we may wish to accept user-defined Field types. This would force us to take (and have the consumer maintain) a separate union.

Utilising extends and type inference

In TypeScript, however, it’s possible to infer the return types of functions using generics and extends. We can redeclare our function as follows:

Logo for TypeScriptTypeScript
function displayData<T extends Field[]>(...fields: T) { return fields.map(f => f.displayData() as ReturnType<T[number]['displayData']>); }

We are now stating that fields is any array which extends an array of the Field interface. Notice that we are not placing our generic in place of Field, but in place of the entire array.

Crucially, when we reference the ‘return type’ of calling T[number]['displayData'], TypeScript is able to dynamically construct the concrete type based on the arguments we passed in. The resulting type is exactly the same as above, but without the need to restrict fields to a predefined union:

Logo for TypeScriptTypeScript
type Result = ( | { type: "text"; required: boolean; maxLength: number; } | { type: "checkbox"; options: { name: string; value: string; checked: boolean }[] } )[];

Matching the input to the output

We can strengthen the types further by returning a tuple. Since we know the length of the original array (and the type of the items within it), we can ensure that the output exactly matches the input:

Logo for TypeScriptTypeScript
function displayData<T extends Field[]>(...fields: T) { return fields.map(f => f.displayData()) as { [K in keyof T]: ReturnType<T[K]['displayData']> }; }

The resulting type then becomes:

Logo for TypeScriptTypeScript
type Result = [ { type: "text"; required: boolean; maxLength: number; }, { type: "checkbox"; options: { name: string; value: string; checked: boolean }[] }, ];

We can now only access the 0 and 1 indices safely:

Logo for TypeScriptTypeScript
result[0]; // ok, typed as text input display result[1]; // ok, typed as checkbox input display result[2]; // error, out of bounds

Have a play

This is incredibly powerful, and something which is challenging or downright impossible to achieve in other statically typed languages. It can be a bit of an escape hatch, and isn’t a substitute for thoughtful design, but if used correctly can enable very powerful type-safe API.

Here’s a playground.

← Archive