Previously on Piccalilli, Sam Rose walked us through some real world uses of TypeScript’s utility types. Today, I want to continue that line of thought and show you a few of TypeScript’s advanced type manipulation features that I think will provide good bang for your buck.
The objective of this article is to provide an overview of each feature, along with a overarching motivating example, so that you have a better awareness of what tools are at your disposal when you’re creating types. Before I get into that, I want to provide a perspective on why you might want these features when they can be quite complicated.
Some useful backgroundpermalink
Sometimes, it’s easy to view types as merely “decorations” or conveniences that go on top of whatever JavaScript you’re writing. I wont deny that you can get a lot done with that mindset! However, TypeScript’s flexibility and power very much reward those who treat types as a first-class part of a program’s design.
Whenever we’re writing software, we’re always creating some kind of model for a particular problem or part of the world, whether we realize it or have a way to formalize it. Types give us a structured way to implement this model by describing the data that exists in our program. The classes and functions in your program describe the behavior, while the types describe the data. They are two sides of the same coin.
In CSS, we abstract out shared details of the design into reusable units (like classes), and we compose different elements of the design together through the cascade. These practices of abstraction and composition help us create maintainable code. Types are no different, and these advanced features help us abstract out commonalities and compose different types together.
Similarly, I might be able to create the same visual effect with Flexbox or Grid, but one or the other might be the correct choice depending on the context. When we’re choosing how to model our data with types, we have to make choices about what kind of complexity we’re willing to tolerate in order to get a particular kind of model. Put another way, just because we can use a language feature doesn’t mean we should. This is especially true for the high-power (but high complexity) features we’re going to look at today.
A motivating examplepermalink
Let’s suppose we’re writing some code for a home monitoring system, and we want to send an event any time something important happens around the home, like a door opening or a moisture sensor detecting water. We could end up with some code like this:
- Code language
- ts
type EventName = | 'open' | 'close' | 'locked' | 'unlocked' | 'moisture' | 'motion'; type Access = { sensorId: string; // Suppose the door opens with a keypad and each person has // their own code. openedBy: string; // Epoch time in milliseconds. Not appropriate for // the real world but means the examples can run as-is. timestamp: number; }; type Moisture = { sensorId: string; timestamp: number; }; type Motion = { cameraId: string; timestamp: number; }; const sendEvent = ( event: EventName, payload: Access | Moisture | Motion ) => { /* ... */ };
The EventName
type describes a list of possible events, while the Access
, Moisture
, and Motion
events describe the data that we want to associate with particular events. The problem with this code is that it does nothing to tie particular event names to particular payloads!
We can associate the events and the payloads by introducing a new type:
- Code language
- ts
type HomeEvents = { open: Access; close: Access; locked: Access; unlocked: Access; moisture: Moisture; motion: Motion; };
The keys of the HomeEvents
type describe the possible event names, and the values of the HomeEvents
type describe the possible event payloads. What is interesting about this type is that we will never end up constructing a value of it. We’re only going to be using it to compute other types.
On its own, HomeEvents
is not particularly useful, but in conjunction with Indexed Access Types we can start to make some progress!
Indexed Access Types are the type-level equivalent of normal property access in JavaScript:
- Code language
- ts
type Company = { name: String; revenue: number; } const edison: Company = { name: "Edison Power Co", revenue: 9001 }; const r = edison['revenue']; // 9001 type Revenue = Company['revenue']; // number
When dealing with a normal JavaScript object, we can look up the value of a property using the []
notation. When dealing with an object type, we can use the same notation to look up the type of a particular property. We are not limited to literal types like 'revenue'
though. We can use a variety of types between the square brackets:
- Code language
- ts
Company['name' | 'revenue'] // string | number
With this new tool in hand, we might try to update our code like this:
- Code language
- ts
type Access = { sensorId: string; openedBy: string; timestamp: number; }; type Moisture = { sensorId: string; timestamp: number; }; type Motion = { cameraId: string; timestamp: number; }; type HomeEvents = { open: Access; close: Access; locked: Access; unlocked: Access; moisture: Moisture; motion: Motion; }; const sendEvent = ( event: string, payload: HomeEvents[string] ) => { /* ... */ };
But what you’ll get is the following error: Type 'HomeEvents' has no matching index signature for type 'string'.(2537)
. In casual terms, HomeEvents
is not able to be used with just any key. What we need is a way to restrict the possible values for that are allowed for event
. One way to do this would be to reintroduce the EventName
type:
- Code language
- ts
type EventName = | 'open' | 'close' | 'locked' | 'unlocked' | 'moisture' | 'motion'; type HomeEvents = { open: Access; close: Access; locked: Access; unlocked: Access; moisture: Moisture; motion: Motion; }; const sendEvent = ( event: EventName, // HomeEvents[string] -> HomeEvents[EventName] payload: HomeEvents[EventName] ) => { /* ... */ };
The code compiles now, but it still has two key issues:
- We’re defining the list of events twice.
- The types of
event
andpayload
are still not tied together.
We can solve the first problem with another tool, the keyof
operator. keyof
takes an object type and produces a union of all its keys:
- Code language
- ts
type Bird = { species: string; color: string; age: number; }; // These two types are equivalent! type BirdProperties = keyof Bird; type _BirdProperties = 'species' | 'color' | 'age'
So, to remove the duplication between the EventName
and HomeEvents
types, we can compute EventName
directly from HomeEvents
using keyof
:
- Code language
- ts
type HomeEvents = { open: Access; close: Access; locked: Access; unlocked: Access; moisture: Moisture; motion: Motion; }; type EventName = keyof HomeEvents; const sendEvent = ( event: EventName, payload: HomeEvents[EventName] ) => { /* ... */ };
This leaves us with one more problem, which is that there’s nothing at that type-level stopping us from mismatching event names and payloads. For example, we could call sendEvent
with the event "motion"
but pass the payload of an Access
event!
We can fix this with some generic types. This change is more involved, so I’m going to lead with the final result and then explain it piece by piece:
- Code language
- ts
/* OLD VERSION const sendEvent = ( event: EventName, payload: HomeEvents[EventName] ) => {}; */ const sendEvent = <E extends EventName,>( event: E, payload: HomeEvents[E] ) => { /* ... */ };
In the final result, we start by introducing a generic type parameter E
to the function sendEvent
. You can think of E
like a variable or function parameter, but at the type level instead of at the value level.
Consider the parallel between functions. We can, of course, write literal expressions to add numbers together, like const sum = 2 + 2
. But, by introducing names to values, and working with only those names (forgetting the details about what specific numbers we’re dealing with), we can write functions that can add any numbers together: const add = (x, y) => x + y;
.
The function parameters x
and y
are like “generics” for the values that we’re going to add. They’re placeholders for specific numbers we want to add together when we call the function later. The same thing is happening with generic types. We’re introducing a placeholder name for a type and filling that placeholder in later when we call the function.
Now, if all we could do is declare bare generic types, they wouldn’t be that helpful. Just like in this example.
- Code language
- ts
const log = <T,>(x: T) { console.log(x); }
Since T
can be any type, the function parameter x
is essentially untyped. If this was all we could do with generic types they wouldn’t be very useful. That’s where the extends
keyword comes in!
The extends
keyword allows us to put restrictions, often referred to as generic type bounds, on what types can be substituted for a particular generic type. Keeping with the comparison to functions, type bounds are like parameter types:
- Code language
- ts
const add = (x: number, y: number) => x + y; // --- type Pet { name: string; age: number; } type Fish = Pet & { waterType: 'fresh' | 'salt'; }; type Cat = Pet & { cuteness: number; }; type Dog = Pet & { tailWagLevel: number; }; const feedPet = <P extends Pet,>(pet: P) => { /* */ };
x
and y
are placeholders for values, but the type number
means that only specific kinds of values can be substituted for them.
Similarly, the type P
in feedPet
has been constrained so that it can only be substituted for the Pet
type or any of its specializations, such as Cat
or Dog
.
To be more specific about what extends
means:
If a type T
extends another type U
, it means values of type T
can be used anywhere a value of type U
is expected.
Back to our motivating examplepermalink
What does all of this knowledge mean for our sendEvent
function? It means that the type of event
has to be a subset of EventName
.
Let’s manually fill in the types that the compiler would be seeing to get an understanding of what’s going on:
- Code language
- ts
// We start with our generic function const sendEvent = <E extends EventName,>( event: E, payload: HomeEvents[E] ) => { /* ... */ }; // When we provide an event name, the compiler can // fill in the rest of the types sendEvent('motion', /* what type is allowed here? */); // Step 1: const sendEvent = <'motion' extends EventName,>( event: 'motion', payload: HomeEvents['motion'] ) => { /* ... */ }; // Step 2: const sendEvent = <'motion' extends EventName,>( event: 'motion', payload: Motion ) => { /* ... */ };
The generic type is binding the type of event
and the type of payload
together such that it’s impossible to mismatch an event name and its payload.
Of course, this is just one example of how keyof
, generics, and indexed access types, can be used together. If you’re more experienced with TypeScript or if you’ve used a discriminated union before, you might be thinking of other ways to design the types for this sendEvent
function. I’ll leave that as an exercise for the reader 😃.
Mapped typespermalink
Now that we have some familiarity with some prerequisites, we can look at one more advanced TypeScript feature: mapped types.
To explain what mapped types are all about, let’s start with talking about “mapping” as an idea in programming. Mapping is typically the idea of applying a function to each element of a collection to produce an updated collection, as in the following example:
- Code language
- ts
const countingNumbers = [1,2,3,4,5]; const evens = countingNumbers.map(n => n * 2); console.log(evens); // [2,4,6,8,10]
The idea can readily be extended to object types, by iterating over the key-value pairs (though the syntax is admittedly cumbersome):
- Code language
- ts
const grossRevenues = { "Edison Power Co.": 5760, "Best Ever Kebabs": 9756, "Tarjan & Co. Networking Service": 10755, "Ford Fulkerson Plumbing": 42665 }; const applyTaxes = ( businessName: string, revenue: number ) => [businessName, revenue - revenue * 0.20] as const; const postTaxRevenue = Object.fromEntries( Object.entries(grossRevenues).map( ([key, value]) => applyTaxes(key, value) ) ); /* alternately: Object.entries(grossRevenues).reduce((acc, [k, v]) => { const [newKey, newValue] = applyTaxes(k, v); acc[newKey] = newValue; return acc; }, {} as Record<string, number>); */
In the example above, we map each business and its gross revenue to a new key-value pair describing its post-tax revenue. We could perform more elaborate mappings where we rename keys, remove keys from the resulting object, produce multiple output keys per input key, and more!
The reason I mention all of this is because mapped types allow us to do very similar things on the type level.
Let’s take a look at an example that expands off our monitoring system from earlier. Suppose we want a type that describes whether a particular system has a particular variety of sensor. But, we also, don’t want to repeat the whole list of event types from earlier. We could write:
- Code language
- ts
// Refresher: type HomeEvents = { open: Access; close: Access; locked: Access; unlocked: Access; moisture: Moisture; motion: Motion; }; // This is a mapped type! type HasSensors = { [SensorName in keyof HomeEvents]: boolean; };
The HasSensors
type expands to:
- Code language
- ts
type HasSensors = { open: boolean; close: boolean; locked: boolean; unlocked: boolean; moisture: boolean; motion: boolean; }; // Some of these keys don't make sense as sensors, like "close". // But, it's good enough to be an example.
Mapped types always produce object types, so it should be unsurprising that the syntax has some similarities. We start with curly brackets, and then inside we write an expression that describes each key-value pair in the object. The “key side” of the expression takes the form [KeyType in KeySourceType]
and you can think of it like a for-loop.
- Code language
- jsx
for (let i of [1,2,3]) { console.log(i); }
In the loop above, i
takes on the value of each of the elements inside the array, first 1
, then 2
, and lastly, 3
.
In a mapped type, KeyType
is like i
and KeySourceType
is like the array. Concretely, in HasTypes
, the SensorName
is first equal to 'open'
, then 'close'
, etc. until it has taken on all of the variants of keyof HomeEvents
. We are “mapping over” — which is looping over — all of the variants from the KeySourceType
.
The job of the “key side” is to determine what the names of the keys are in the new object type. The job of the “value side” — on the right side of the :
character — is to determine the types of the corresponding values.
Putting it all together, when we write:
- Code language
- ts
type HasSensors = { [SensorName in keyof HomeEvents]: boolean; };
We are saying, in plain language: “Make an object type from the keys of the HomeEvents
and make the type of each property to be boolean
.”
At this point, you might be thinking “that’s not very useful”, but mapped types can do much more. First of all, they can be (and often are) generic types, so we can upgrade our HasSensors
type to work with any object type:
- Code language
- ts
type BooleanProperties<Type> = { [Key in keyof Type]: boolean; };
The MakeBoolean
type works exactly the same as HasSensors
, except we can apply it to any object:
- Code language
- ts
type DrawingTools = { pencil: () => {}; fill: () => {}; pen: () => {}; }; type WhichToolsActive = BooleanProperties<DrawingTools>; /* Same as: { pencil: boolean; fill: boolean; pen: boolean; } */
We can also use mapped types to rename the keys of an object type. To do this, we need an additional bit of syntax. Let’s look at an example that once again builds on our home monitoring system from earlier:
- Code language
- ts
type EventGetters = { // Breaking up a mapped type over mutiple lines like this is // rather unusual. I'm only doing it to help with readability // on narrower screens. [ SensorName in keyof HomeEvents // This is the new syntax! as `get${Capitalize<SensorName>}Event` ]: (eventId: string) => HomeEvents[SensorName] } /* Turns into: { getOpenEvent: (eventId: string) => Access; getCloseEvent: (eventId: string) => Access; ...etc getMoistureEvent: (eventId: string) => Motion; }
The as
section of the “key side” allows us to convert each variant of HomeEvents
into a new type. You can think of it like this code:
- Code language
- ts
// Only using 3 values for brevity // proprtyNames is like `keyof HomeEvents` const propertyNames = ["open", "close", "moisture"]; // `newPropertyNames` is like the final keys of the // mapped type. const newPropertyNames = propertyNames.map( // This function is the `as` clause (property) => `get${capitalize(property)}Event` ); // The `Capitalize` utility is built into TypeScript, // but we have to implement it for normal string values. const capitalize = (s: string): string => { return s[0].toLocaleUpperCase() + s.slice(1); };
If we want to perform some sort of modification to an object’s value types, more-or-less all we have to do is write the expression for it.
As a contrived example:
- Code language
- ts
type WithAuditor<T> = T & { auditorId: string; auditedOn: Date; }; // Implementations for all these types are not important. // You can assume they're complicated object types. type BusinessRecords = { notesAnDisclosures: NotesAndDisclosures; profitLoss: ProfitLoss; retainedEarnings: RetainedEarnings; }; type ApplyAudit<Type> = { [Key in keyof Type]: WithAuditor<Type[Key]>; } type Audit = (records: BusinessRecords) => ApplyAudit<BusinessRecords>;
Finally — though we don’t have time to cover conditional types in this post — I want to mention that mapped types become especially powerful when combined with conditional types. A “conditional mapped type” can be recursive over nested objects (like DeepPartial
from Sam Rose’s Utility Type article), or capable of filtering out keys of an object based on the types of the values, as in:
- Code language
- ts
type ModalProps = { show: boolean; greeting: string; onClose: () => void; onOpen: () => void; }; // You can implement this with a conditional mapped type! type ModalFunctions = FunctionsOnly<ModalProps>; /* { onClose: () => void; onOpen: () => void; } */
Wrapping uppermalink
We’ve covered a ton of ground today in this whirlwind tour. We’ve seen:
- Indexed access types
- The
keyof
operator - Basic generic types
- Generic type bounds through the
extends
keyword - Mapped types
All of these features are individual tools in a toolbox for building models of the world. Through careful selection and composition of these tools, you can build robust, maintainable, and high-fidelity representations of your program’s data.
Remember, just like any powerful construct in programming, over-applying any of the features listed today can cause quality issues in your code. Be selective in what types you refactor with more complex constructs, and don’t be afraid to re-implement the same data-model a few times if it’s not quite working. TypeScript’s flexibility means that there is often more than one way to implement a particular set of types, each with different trade-offs.
‘Til next time, and remember: parse, don’t validate.