The true power of TypeScript generics
I promised at the end of my last TypeScript article on singleton types to share more thoughts about TypeScript’s true power. As I said then, TypeScript is often (wrongly) assume to be stripped of the kind of expressive flexibility that JavaScript — with the kind of “anything goes” approach to typing — affords. In this article, we shall pick another awesome aspect of TypeScript to debunk this myth. And what other aspect is better suited to debunk the inflexibility myth than the topic of generics?
Let’s go.
Generics as generalization
“You spend your entire life studying a bucket. Once you understand the one bucket fully, the others are all the same.”
— Ajahn Chah on understanding the mind.
If you have programmed in other languages that feature the concept of templates and generics in some form, this section may not be interesting to you. :)
Living in Europe for so long, one of the observations I like is that when spring approaches, people would start putting on summer wear when the temperature reaches about 10 degrees celcius. (Having come from Thailand, I think that’s crazy, but that’s beside the point.) Curiously, as winter looms, we start wrapping up in our winter wear way before it falls to 10 degrees. It’s often not the absolute situation in life but rather its change that motivates us to do things.
Now that I’m done indulging in a soliloquy, let’s use the little story above in demonstrating how generics work in TypeScript. We could model the changes in temperature and changes in clothing in TypeScript-land like this:
interface ChangeInTemperature {
oldValue: number;
newValue: number;
}interface ChangeInClothing {
wasWearingWinterClothes: boolean;
isWearingWinterClothes: boolean;
}
Suppose we have to inspect this change-in-temperature object to see if there really is a change, we could write a function like so:
function hasTemperatureChanged(change: ChangeInTemperature){
return change.oldValue !== change.newValue; // inferred boolean
}
We can just as well inspect the change in clothing to the same intention; that is, see if there is a change:
function hasClothingChanged(change: ChangeInClothing){
return change.wasWearingWinterClothes !==
change.isWearingWinterClothes;
}
Now, both of these functions are essentially doing the same thing: they inspect an object with property semantics of something that represents an older value, and something that represents a newer value. The only differences are that:
- The properties were named differently to be semantically precise.
- The properties were of different types because of the nature of information they carry.
Now, let’s suppose further that we also want to represent what we think about the weather (“gorgeous”, “pleasant”, “meh”, “awful”) and how that has changed. We would also need a similar kind of declaration to cover the functionality we have given to the other two “change” objects:
type WeatherOpinion = "gorgeous"|"pleasant"|"meh"|"awful";interface ChangeInWeatherOpinion {
oldOpinion: WeatherOpinion;
newOpinion: WeatherOpinion
};function hasWeatherOpinionChanged(change: ChangeInWeatherOpinion){
return change.oldOpinion !== change.newOpinion;
}
Another interface, another type, but still exactly the same concept.
This is the kind of situation where TypeScript generics may help you. We can define a generic interface that represents a change in value. We don’t really know what type that value will take, but we know that the old value and the new value will share the same type:
interface ChangeRecord<ValueType> {
oldValue: ValueType;
newValue: ValueType;
}
We will refer to the yet-unknown value type which should be the same between the old and new values as ValueType
. The name could be anything, as long as it is consistent within the <>
and where it’s being used. Our ValueType
is a type name that only makes sense within the definition of the interface, and it is called a generic type parameter.
So how do we use this interface to describe temperature changes, clothing changes, and weather opinion changes?
Since temperature is a number,a change in temperature is a change in numeric value. Therefore, we could express ChangeInTemperature
as ChangeRecord<number>
.
type ChangeInTemperature = ChangeRecord<number>;
Here, we are telling TypeScript to define ChangeInTemperature
as a specialization of the generic interface ChangeRecord
. Wherever we had put the generic type parameter ValueType
inside the declaration of ChangeRecord
, we should now replace it with number
. In this place, number
is a generic type argument. The resulting type for ChangeInTemperature
is therefore:
type ChangeInTemperature = {
oldValue: number; // ValueType becomes number
newValue: number; // ValueType becomes number
};
We could now do the same for our clothing and weather opinion changes. Because we’re thinking generically we have to accept that we’re going use the same properties to represent the notion of “old” and “new” respectively. So to make oldValue
and newValue
meaningful in all cases, we’ll rename our types slightly:
// true means "wearing winter clothes", false otherwise
type ChangeInWinterWear = ChangeRecord<boolean>;type ChangeInWeatherOpinion = ChangeRecord<WeatherOpinion>;
What about our three functions that check whether a change actually exists? That, too, can be generalized into a single generic function:
function isChanged<ValueType>(change: ChangeRecord<ValueType>){
return change.oldValue !== change.newValue;
}
Adding constraints to generics
In our isChanged
function above, we are performing a strict equality ===
check on the old and new values. This only makes sense for primitives. But our generic so far will let through absolutely everything as long as the old and new values are of the same type, resulting in a use case that may be unintended.
To stop this, we can add an extends
clause to say what kind of type is allowed to be provided as generic type argument to ChangeRecord
.
interface ChangeRecord<ValueType extends string|number|boolean> {
/* ... */
}
Now an attempt to create a type ChangeRecord<object>
will fail.
Debugging generic type-check
Let’s test out our shiny generic types.
const test1: ChangeInWinterWear = {
oldValue: false,
newValue: true
};
isChanged(test1); // type OKconst test2: ChangeInWeatherOpinion = {
oldValue: "gorgeous",
newValue: "meh"
};
isChanged(test2); // type OK
interface NotAChangeRecord {
oldValue: number;
newValue: string;
}
const test3: NotAChangeRecord = {
oldValue: 42,
newValue: "blah"
};
isChanged(test3); // type ERROR
If you go over to the TypeScript playground with the code above and hover on each use of isChanged
, you will see that TypeScript has inferred the type of argument for us.
In the final instance, the oldValue
and newValue
are not of the same type. Since they are not of the same type, they cannot possibly represent a ChangeRecord
type regardless of what ValueType
we would give it. Therefore, TypeScript gives us this error:
Argument of type 'NotAChangeRecord' is not assignable to parameter of type 'ChangeRecord<number>'.
Types of property 'newValue' are incompatible.
Type 'string' is not assignable to type 'number'.
Niceness. Generic type-check: check!
But before we celebrate, let’s just look at that error message again.
Argument of type 'NotAChangeRecord' is not assignable to parameter of type 'ChangeRecord<number>'.
Types of property 'newValue' are incompatible.
Type 'string' is not assignable to type 'number'.
The compiler clearly had to try to fit the NotAChangeRecord
type into ChangeRecord<number>
and ChangeRecord<string>
, since they were the closest possibilities. Of course, neither fitted because our object had a string
as one property and a number
as the other. That there is an error clearly makes sense.
However, behold here what I consider a developer experience fail. What the compiler did then was to blurt out— quite arbitrarily, mind you— just the one attempt at matching ChangeRecord<number>
. In this case, both the string
and number
specialization are just “as mismatched” as the other. I have seen cases where I make a mistake while trying to match specialization A, and TypeScript blurts out errors from trying to match specialization B, which is unfortunately not helpful.
It’s as if you’re trying to go to some bureaucratic department to ask for a particular form and the way you phrase your technical words make the clerk think you’re trying to register a ship, from which point on the conversation is all about going to the port. TypeScript, as brilliant as it is, behaves a bit like that clerk sometimes.
The bottom line here is:
If you run into a generic type-check error, don’t take TypeScript’s message too literally. Remember what *your* own intentions are.
Read more on generics
Lots have been written about how to use generics in practice out there. If you’re completely new to the idea, or to TypeScript, or both, the TypeScript handbook is a great place to start.
I really want to move on to what makes generics in TypeScript so awesome, namely…
Generics as type meta-programming
In TypeScript, generics can do so much more than specializing classes, interfaces or functions, thanks to the various syntactic constructs operating at type level which let you create new types programmatically. Remember that TypeScript doesn’t emit any JavaScript that has anything to do with type declarations (with enums being the only exception for technical reasons) so the expressions I will show you will add type dynamicity and strictness without any burden on your JavaScript output.
In this part, I will show you a family of “maybe” types that you might find useful, which will demonstrate the TypeScript’s ability to meta-program types in your code.
Generic unions
In my previous article I have used the union operator. Let’s revisit what this means:
type NumberOrString = string|number;
Simple as that. A numeric value or a string value matches type NumberOrString
. The type operator |
is the union operator and it means that the type on either side counts as a match.
const test1: NumberOrString = 42; // OK
const test2: NumberOrString = "hello"; // OK
const test3: NumberOrString = false; // Type Error
So we could also define a type of “something or null”. Let’s suppose, string-or-null:
type StringOrNull = string|null;
const test1: StringOrNull = "hello"; // OK
const test2: StringOrNull = null; // OK
const test3: StringOrNull = 42; // Type Error
Of course, other variants of “something or null” would make sense too:
type NumberOrNull = number|null;
type BooleanOrNull = boolean|null;
That’s right, we’re also looking at a generic here! To express a type that represent “something or null”, we could do this:
type MaybeNull<ValueType> = ValueType|null;
const test1: MaybeNull<string> = "hello"; // OK
const test2: MaybeNull<number> = null; // OK
const test3: MaybeNull<boolean> = 42; // Type Error
Now, that’s nice. But it gets even nicer when we then leverage TypeScript’s well-intended attempt to resolve types from existing information as far as possible. Look at this function:
function requireValue<ValueType>(input: MaybeNull<ValueType>){
if (input === null) throw Error("value is required!");
return input;
}
One could use this function as a dare-devil way of saying “I know this value could technically — per type declaration — be null, but I also know that in this particular instance it could never be. So shut up and give me my non-null value and let me pay for my own assumption should it ever prove incorrect.” Because null
has been throw
n away, this function returns just the non-null part of your argument. Therefore, TypeScript will generically mark the result of this function as ValueType
.
// suppose "test1" is of type MaybeNull<string>
requireValue(test1); // string// suppose "test2" is of type MaybeNull<number>
requireValue(test2); // number
The reason why TypeScript can “de-unionize” the input argument and gives us just the non-null portion is thanks to the generics.
“But I can do this in other languages with nullable support too!”, you said. Yes indeed, so let’s look at something else.
Call me maybe?
If you have done any amount of production JavaScript, you will have come across a configuration hell where some setting takes a string, a regex, an object, or a function, and it all does something different in very subtle ways. (If you use Webpack, this is still my favourite meme.)
That said, there is still merit in polymorphic configuration. Let’s consider a problem in this direction: suppose you want to allow a configuration that either takes a number or function of certain arguments that returns a number. That is to say, for instance:
{
happinessScoreToday: 10
}
or
{
happinessScoreToday: weatherOpinion =>
weatherOpinion === "gorgeous" ? 10 : 5
}
What is the type of happinessScoreToday
?
As a one off, we could say that it’s either a number, or a function that returns a number. That is to say:
type HappinessScoreConfig =
| number
| (weatherOpinion: WeatherOpinion) => number;
That’s one configuration. Suppose all your configurations take a similar constant-or-function form. We can generalize this kind of union into a MaybeConstant
generic type, which we will now define:
type MaybeConstant<Function extends (...args: any[]) => any> =
Function|ReturnType<Function>;
This generic defines a type that is either the function type provided as generic type argument, or its return type as a constant. The magic built-in type ReturnType
is itself a generic — we will revisit this soon — that extracts the return value of the function type argument.
With this, we can rewrite our happiness score config type as follows:
type HappinessScoreConfig =
MaybeConstant<(weatherOpinion: WeatherOpinion) => number>;
That’s not much shorter, but it’s semantically very clear what we’re trying to do here. Also note that TypeScript will discriminate the alternations just fine when you actually try to use the type:
function useConfig(config: HappinessScoreConfig): number {
// return config; // can't do this, might be function!
// return config("gorgeous"); // can't do this, might be number!
if (typeof config === "number") return config; // OK
return config("gorgeous"); // also OK, can only be function.
}
Promise me… maybe?
You have a function that may complete synchronously or asynchronously but must eventually return a value of a certain type?
type MaybePromise<ValueType> = ValueType|Promise<ValueType>;
You could even combine it with the MaybeConstant
we already defined earlier:
type ResolveHappinessConfig =
MaybeConstant<(weatherOpinion: WeatherOpinion) =>
MaybePromise<number>>;
The type ResolveHappinessConfig
accepts a number
, a function accepting a WeatherOpinion
that returns a number
, a Promise
that resolves to number
, or a function that returns a Promise
that resolves to number
. Woooo!
Give me a lot… maybe?
Your API can accept a single query or multiple queries? No problem:
type MaybeArray<ValueType> = ValueType|ValueType[];
Undoing generic unions
We have defined a few “maybe” types that are generic unions: MaybeArray
, MaybePromise
, MaybeConstant
and MaybeNull
. We have also shown how to generically extract the non-null type from the MaybeNull
type and extract the constant type from the MaybeConstant
using the “magic” ReturnType
.
Using similar techniques, can you correctly type these functions?
- a function that would take a
MaybeArray
argument and return either the only item (if it’s not an array) or the first item (if it is an array)? - a function that would take a
MaybePromise
argument and always resolve asynchronously to the resolved value (if wrapped in a promise) or the input value (if not a promise)?
Infer
I said earlier that I would explain the ReturnType
generic and how it is able to extract the return argument from a function type.
Let’s look at the definition of ReturnType
type, which is built in with TypeScript.
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;
“infer
?! what are you letting me infer now?” (If you didn’t get that, I’m sorry. If you did get that, … I’m sorry.)
We are seeing two constructs that play closely together. First, the “extend
-tenery-operator-looking-thing” takes this form:
type X = A extends B ? C : D;
This means, type X
is the same as type C
if type A
extends type B
. Otherwise, type X
is the same as type D
. This construct A extends B ? C : D
is known as the conditional type expression, and it features the conditional type operator ... extends ... ? ... : ...
, which is conceptually not unlike the ternery operator we know and love in JavaScript.
Next, we see the keyword infer
. Note that this keyword appears in front of the type R
, which we did not so far declare anywhere.
Look at our conditional type A extends B ? C : D
again, the main part of the condition — the predicate if you will — is type B
, because it determines whether the whole type expression will resolve to one branch or another. In this B
position, we can have a complex type expression involving many types, such as (...args: any[]) => any
. Instead of hard-coding a type in this expression, what we can do instead is to substitute a type with infer <Identifier>
, such as (...args: any[]) => infer R
. TypeScript will then attempt to match the input type (here A
) to this type the best it can. Once matched, the type that match the infer
position will be “captured” into that identifier.
infer
can be scary to read at first, so let’s get practical and look at ReturnType
definition again:
type ReturnType<T> =
T extends (...args: any[]) => infer R ? R : any;
It’s saying:
- The return type of
T
is… - Well, is
T
a function? While you’re trying to match it to a function, find out what the return type is (T extends (...args: any[]) => infer R
) - If
T
is a function, then the type resolves to the inferred return typeR
. - Otherwise the type resolves to
any
.
To better understand how infer
works, let’s practice it on our various Maybe*
types. How can we create a type that converts a type to the element of an MaybeArray
if
type MaybeArrayItem<T> = ... ?
Did you get something like this?
type MaybeArrayItem<T> =
T extends Array<infer Element> ? Element : T;
That is to say, if my type is an array, extract the element, otherwise the input type is exactly the result?
Try generically “undoing” the types MaybePromise
, MaybeConstant
and MaybeNull
into the “important parts” (the resolved value, the return value, and the non-null value) using infer
. (Answers here)
Wrapping up
Conditional type expressions and infer
operator together form part of the basic building blocks allowing TypeScript to express a seemingly arbitrarily complex type logic. It is this ability that allows TypeScript to support the dynamic nature of your codebase’s types that, for me, truly sets TypeScript aside from the sterotype of inflexibility derived from typed languages. For me, this also frees TypeScript from the general criticism against bringing typing into JavaScript on grounds of incompatibility with the latter’s existing idioms.
There is still much more that generic type expressions can do. Later, in a different article, I will show more TypeScript metaprogramming, what it can be used for beyond the obvious, and some of the developer experience observations surrounding it. Till then!