Engineering

Utility types in TypeScript

Written By

Monroe Ekilah

Illustration of code snipped for Mercury Engineering blog
Copy Link
Share on Twitter
Share on LinkedIn
Share on Facebook
Linegraph tracking a Mercury account balance
Build the next generation of startup bankingExplore Openings*Mercury is a financial technology company, not a bank. Banking services provided by Choice Financial Group and Evolve Bank & Trust®; Members FDIC.
Copy Link
Share on Twitter
Share on LinkedIn
Share on Facebook

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:

Copy Code
/**
 * 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.

Copy Code
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 NonNullableExclude 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:

Copy Code
/**
 * 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:

Copy Code
function removeNumbers(list)

How can we nicely model this function with TypeScript? There are a few common, easy-way-out options:

Copy Code
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:

Copy Code
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:

Copy Code
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:

Copy Code
/**
 * 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:

Copy Code
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:

Copy Code
/**
 * 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:

Copy Code
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:

Copy Code
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:

Copy Code
type UserData = Omit<UserDataFromAPI, 'birthdate' | 'signedUpAt'> & {
  birthdate: Date
  signedUpAt: Date
}

ExcludeExtract, 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:

Copy Code
/**
 * 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:

Copy Code
type Baz = {
  a?: number
  b: string | undefined
}

Which of these do you think Required<Baz> will be equivalent to?

Copy Code
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:

Copy Code
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:

Copy Code
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 consts, 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:

Copy Code
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:

Copy Code
/**
 * 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. RecordParameters, 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

This post was originally published at https://blog.ekilah.dev/posts/utility-types-in-typescript in January 2022.

Notes
Written by

Monroe Ekilah is a principal software engineer at Mercury.

Share
Copy Link
Share on Twitter
Share on LinkedIn
Share on Facebook