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.
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:
// 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 },
],
};
}
}TypeScript
If we wanted to aggregate display data for a set of fields, we could write:
function displayData(...fields: Field[]) {
return fields.map((f) => f.displayData());
}
const result = displayData(new TextInput(), new CheckboxGroup());TypeScript
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:
type Result = { type: string }[];TypeScript
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:
type AllFields = TextInput | CheckboxGroup;
function displayDataUnion(...fields: AllFields[]) {
return fields.map((f) => f.displayData());
}TypeScript
This gives us an accurate final return type, including all possible metadata fields:
type Result = (
| { type: "text"; required: boolean; maxLength: number }
| {
type: "checkbox";
options: { name: string; value: string; checked: boolean }[];
}
)[];TypeScript
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:
function displayData<T extends Field[]>(...fields: T) {
return fields.map(
(f) => f.displayData() as ReturnType<T[number]["displayData"]>,
);
}TypeScript
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:
type Result = (
| { type: "text"; required: boolean; maxLength: number }
| {
type: "checkbox";
options: { name: string; value: string; checked: boolean }[];
}
)[];TypeScript
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:
function displayData<T extends Field[]>(...fields: T) {
return fields.map((f) => f.displayData()) as {
[K in keyof T]: ReturnType<T[K]["displayData"]>;
};
}TypeScript
The resulting type then becomes:
type Result = [
{ type: "text"; required: boolean; maxLength: number },
{
type: "checkbox";
options: { name: string; value: string; checked: boolean }[];
},
];TypeScript
We can now only access the 0 and 1 indices safely:
result[0]; // ok, typed as text input display
result[1]; // ok, typed as checkbox input display
result[2]; // error, out of boundsTypeScript
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.