TypeScript (TS) offers a host of improvements over vanilla JavaScript with its compile-time type system. Codebases small and large can get a lot of benefits from the lightest applications of TS. The simplest, out-of-the-box features of TS can prevent whole classes of errors from occurring at run time and make collaborating way easier.
However, additions like that (e.g. telling TS which variables are expected to be string
vs. number
) are just the tip of the iceberg. Experienced TS developers often seek out more advanced tools to squeeze every last drop out of the type system. By doing so, they can unlock additional layers of type safety by communicating more about the intent and meaning of their code to the TS compiler.
Utility types, our topic for today, are an essential tool in a TS developer’s toolbox for doing just that. They give you ways to define relationships between types and to operate on existing types to make new ones. Utility types are both fun to explore and fairly powerful, once you get familiar with them, so let’s dive in!
Built-in utility types
TypeScript actually ships with several utility types that are quite useful. These can be used as-is and also as building blocks when making your own, more advanced utility types (more on that later). Here, we’ll walk through a few of the built-in utility types with some example use cases.
NonNullable
Let’s start with a simple one, NonNullable<T>
, and break down how it works and how/why you might use it. Starting with the definition:
/**
* Exclude null and undefined from T
*/
type NonNullable<T> = T extends null | undefined ? never : T
As the documentation suggests, this utility type takes an input type (T
), removes null
and undefined
from it (if present), and returns the result. never
here is TypeScript’s way of removing something from a type in these type-level ternary statements, called Conditional Types.
Let’s see an example. Given a type like Foo
, we can programmatically create a related type Bar
, where Bar
is like Foo
but doesn’t include either of JavaScript’s nullish types.
type Foo = string | boolean | null
type Bar = NonNullable<Foo> // Bar evaluates to `string | boolean`
Now, remember, this is a type-level operation; as with all TypeScript typings, this is a compile-time construct. NonNullable
doesn’t do anything to your data at runtime, it’s just a way to change the type Foo
into something else. It’s still up to you to actually transform your data from one type to another.
Don’t miss a big benefit to defining Bar
in terms of Foo
here: if, for example, we decide to add number
to the list of types in Foo
later, Bar
will automatically also support number
without human intervention. This kind of refactoring safety is a big benefit that utility types can provide you!
Note: This blog post was written before this 2022 pull request tweaked the definition of NonNullable
. It is now defined as T & {}
, which improved the way NonNullable
reduces in more complex types, but didn’t change anything about how or why you’d use it.
Exclude
/ Extract
Exclude
and Extract
help you pick and choose which members of a union type to keep or discard, similar to NonNullable
. But, unlike NonNullable
, Exclude
and Extract
let you choose exactly what to keep or discard, rather than assuming you want to discard null
and undefined
all the time.
You’ll notice that the definitions of these two even look strikingly familiar to NonNullable
:
/**
* Exclude from T those types that are assignable to U
*/
type Exclude<T, U> = T extends U ? never : T
/**
* Extract from T those types that are assignable to U
*/
type Extract<T, U> = T extends U ? T : never
In fact, Exclude<Foo, null | undefined>
is equivalent to NonNullable<Foo>
.
Let’s walk through one example use case for Exclude
. Say you have a function in your codebase that removes all the numbers from a heterogeneous list:
function removeNumbers(list)
How can we nicely model this function with TypeScript? There are a few common, easy-way-out options:
function removeNumbers(list: any[]): any[]
function removeNumbers<T>(list: T): T[]
But surely we can do better. Neither of these tells TS that the return value’s type should no longer include number
. What we need is a way to relate the input type and the output type, but remove number
. This is a great use case for Exclude
and some generics:
function removeNumbers<T>(list: T[]): Exclude<T, number>[]
Now, if we have an input list of number | string
, for example, TS will understand that the result of removeNumbers
is a list of string
, instead of seeing number | string
still, or worse, any
.
Pick
/Omit
Have you ever had a type
or interface
representing an object’s structure in TypeScript that you wanted to replicate, but with a few changes? Pick
and Omit
are perfect for that.
If you’re thinking that this all sounds similar to Exclude
and Extract
, you’re right - but they are slightly different. Pick
and Omit
are to object types as Extract
and Exclude
are to union types; both pairs of utility types let you keep or discard certain pieces of a given type. Remembering which is which can even be difficult sometimes! If anyone out there has a good memory device for this, let me know. 😅
Pick
A common use case for Pick
comes up frequently for us in our React codebase at Mercury. Let’s say you have two components in a parent-child relationship, and several of the props
passed to the child are also passed to the parent from some other component. For example, say all three of these props are passed to the parent, used there, and also passed to the child:
type SharedProps = {
user: UserData
business: BusinessData
options: SomeOptions
}
The parent and child both have other props, so you can’t reuse the same type for both. Since the components only share some props, it’s tempting to copy/paste the shared parts to both components and move on. However, this can make things more difficult as your components and codebase grow:
- Refactoring is harder — if you change one of these shared props’ type, you have to change it in many places.
- Three shared props is probably fine, but it doesn’t take long for this to grow to a much longer list of shared props.
- You could export the
SharedProps
type above, but this pattern might get old: you have to define props in a shared location every time you have a parent/child relationship like this, give the shared type a good name, and pollute the global namespace with this extra export.
The first two points can be summarized as Don't Repeat Yourself
! We can use TS’s built-in Pick
to DRY this code up nicely — here’s the definition:
/**
* From T, pick a set of properties whose keys are in the union K
*/
type Pick<T, K extends keyof T> = {
[P in K]: T[P]
}
Don’t get too hung up on the syntax here. Pick
takes an input (object) type, and, given a list of keys you want to keep from that object type, returns a type with only the matching key/value pairs from the input. It basically lets you filter
an object type by key.
So for our example, we can reuse the child’s props type in the parent’s props type like so:
type ParentProps = Pick<ChildProps, 'user' | 'business' | 'options'> & {
parentOnlyProp1: number
parentOnlyProp2: string
// ...
}
Now, if there’s a fourth prop both components want to share later, you have fewer places to edit and keep in sync. It’s also obvious to people reading this code later that there is a direct and intentional relationship between the props types of both components. 🎉
Omit
Omit
is, roughly speaking, the exact opposite of Pick
, in that it allows you to specify which keys to leave out of the resulting type, rather than which to keep. In fact, the definition of Omit
builds on Pick
:
/**
* Construct a type with the properties of T except for those in type K.
*/
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>
For example, say you have a type like this that represents data you read from a JSON API:
type UserDataFromAPI = {
id: string
name: string
birthdate: string
signedUpAt: string
email: string
// ...
}
Somewhere in your application, you decide to start rendering those dates (birthdate
and signedUpAt
), and get tired of calling new Date()
every time you need a Date
object from those date strings. So, what you really want is this type instead:
type UserData = {
id: string
name: string
birthdate: Date
signedUpAt: Date
email: string
// ...
}
Now, you could make that type manually, which would work fine for a while. But, as soon as you start making edits to UserDataFromAPI
, you’ll realize that it’s annoying to have to remember to manually update UserData
as well. And as your team grows, you worry that other developers won’t be aware of this tightly-linked relationship between UserDataFromAPI
and UserData
. Omit
to the rescue!
With Omit
, we can create UserData
in such a way that it stays in sync with UserDataFromAPI
over time, while still managing to make the edits we want to make to it.
Omit
takes two inputs: the object type to modify and the list of keys to remove from that type. We can then combine the output of Omit
with an inline type that uses Date
like we wanted:
type UserData = Omit<UserDataFromAPI, 'birthdate' | 'signedUpAt'> & {
birthdate: Date
signedUpAt: Date
}
Exclude
, Extract
, and Omit
all suffer from a nuanced issue: they don’t actually validate that the provided types/keys you specify for removal/extraction are actually members of the source type.
For example, Omit<UserDataFromAPI, 'doesNotExist'>
will compile just fine, and will be equivalent to UserDataFromAPI
. There are fixes for this in a package called type-zoo via ...Strict
varieties of these types (e.g. OmitStrict
). These "strict" variants will throw compiler errors if you have a typo in an excluded/extracted/omitted name, or miss something during a refactor, which is really useful!
Pick
does not suffer from this problem, because it requires in Pick<T, K extends keyof T>
that K extends keyof T
.
Required
As mentioned above, Exclude
and Omit
are pretty similar in concept; the former is for union types and the latter is for object types. So is there something like NonNullable
, which removes nullish types from a union, but for object types?
Required
is pretty close to what we’re looking for! Here’s its definition:
/**
* Make all properties in T required
*/
type Required<T> = {
[P in keyof T]-?: T[P]
}
As you might guess from this new syntax -?
, Required
removes any optionality (the ?
operator) from all keys of an object type.
So why did I say it is “pretty close” to what NonNullable
does? Take this example type:
type Baz = {
a?: number
b: string | undefined
}
Which of these do you think Required<Baz>
will be equivalent to?
type This = {
a: number
b: string
}
// or
type That = {
a: number
b: string | undefined
}
It turns out that Required
only removes the ?
from a
. It does not actually change b
at all, so Required<Baz> = That
.
Here’s a TS playground link that demonstrates this in more detail, if you’re curious.
If Baz
’s a
was instead written as a?: number | undefined
, you’d notice some likely-unexpected behavior from TypeScript’s Required
. See this issue for more information if you want some extra credit!
Required
can be useful when modeling optional options, say for a dollar value formatting function. Your user-facing type with optionality might look like this:
type OptionalOptions = {
decimalPlaces?: number
hideDollarSign?: boolean
}
Your formatting function’s internals might need to define the set of defaults to use if some/all of the OptionalOptions
aren’t set by the user. Required
is a great tool to use there:
const defaults: Required<OptionalOptions> = {
decimalPlaces: 2,
hideDollarSign: false,
}
function dollarValueFormatter(dollars: number, options?: OptionalOptions) {
const fullOptions: Required<OptionalOptions> = {
decimalPlaces: options?.decimalPlaces ?? defaults.decimalPlaces,
hideDollarSign: options?.hideDollarSign ?? defaults.hideDollarSign,
}
// Format the input using fullOptions
}
Defining defaults
and fullOptions
this way will require that any additions or changes to OptionalOptions
are also reflected both const
s, whether the developer making those changes knew about them or not. For example, if someone adds superscriptCents
to OptionalOptions
but forgets to add a default value for that key to defaults
, they will get an error:
type OptionalOptions = {
decimalPlaces?: number
hideDollarSign?: boolean
superscriptCents?: boolean
}
// Property 'superscriptCents' is missing in type
// '{ decimalPlaces: number; hideDollarSign: false; }'
// but required in type 'Required<OptionalOptions>'.
const defaults: Required<OptionalOptions> = {
decimalPlaces: 2,
hideDollarSign: false,
}
// (the same error would also be produced on `const fullOptions`)
Leveraging the TS compiler to make sure related code is updated when a type changes is a huge win!
Partial
Partial
naturally follows Required
- Partial
will add optionality (via ?
) to every key of an object type for you. Here’s its definition:
/**
* Make all properties in T optional
*/
type Partial<T> = {
[P in keyof T]?: T[P]
}
If you’d like an example, think about how you could use Partial
to derive OptionalOptions
above, if you started with a type without the ?
s first.
We’ll leave the rest for you to explore on your own. Record
, Parameters
, and ReturnType
are some good ones to familiarize yourself with too, if you want a place to start!
Make your own
Using TS’s built-in utility types, you can construct your own utility types that are either easier to reuse, have nicer names, or do more specific things you find useful.
In my opinion, the main challenge is mastering the relatively odd syntax TS uses to define them. To get started, I’d recommend studying the rest of the built-in utility types and then exploring other open-source libraries that make their own.
Speaking of open-source libraries, here are some links for you to explore, when you’re ready for more advanced utility types:
- type-zoo
- Mostly simple utility types that could/should be included with TS itself
- type-fest
- ts-toolbelt
- The author of
ts-toolbelt
also wrote a detailed blog post on how he used utility types like these to model some of Ramda’s more complicated functions
- The author of
This post was originally published at https://blog.ekilah.dev/posts/utility-types-in-typescript in January 2022.
Monroe Ekilah is a principal software engineer at Mercury.