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.

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.
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:
TypeScript// 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:
TypeScriptfunction 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:
TypeScripttype 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:
TypeScripttype 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:
TypeScripttype 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:
TypeScriptfunction 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:
TypeScripttype 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:
TypeScriptfunction 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:
TypeScripttype 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:
TypeScriptresult[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.