DEV Community

Cover image for TypeScript Utility Types You Need to Know
Steve Sewell for Builder.io

Posted on • Edited on • Originally published at builder.io

TypeScript Utility Types You Need to Know

Are you ever building something in TypeScript and realize...

Screenshot of code that needs a type that is not exported from a package

AGH! This package is not exporting a type I need!

Fortunately, TypeScript gives us a number of utility types that can solve this common problem.

For instance, to grab the type returned from a function, we can use the ReturnType utility:

import { getContent } from '@builder.io'
const content = await getContent()
// 😍
type Content = ReturnType<typeof getContent>
Enter fullscreen mode Exit fullscreen mode

But we have one little problem. getContent is an async function that returns a promise, so currently our Content type is actuallyPromise<Content>, which is not what we want.

For that, we can use the Awaited type to unwrap the promise and get the type of what the promise resolves to:

import { getContent } from '@builder.io'
const content = await getContent()
// ✅
type Content = Awaited<ReturnType<typeof getContent>>
Enter fullscreen mode Exit fullscreen mode

Now we have exactly the type we needed, even though it is not explicitly exported. Well, that’s a relief.

But what if we need argument types for that function?

For instance, getContent takes an optional argument called ContentKind that is a union of strings. I really don’t want to have to type this out manually, so let’s use the Parameters utility type to extract its parameters:

type Arguments = Parameters<typeof getContent>
// [ContentKind | undefined]
Enter fullscreen mode Exit fullscreen mode

Parameters gives you a tuple of the argument types, and you can pull out a specific parameter type by index like so:

type ContentKind = Parameters<typeof getContent>[0]
Enter fullscreen mode Exit fullscreen mode

But we have one last issue. Because this is an optional argument, our ContentKind type right now is actually ContentKind | undefined, which is not what we want.

For this, we can use the NonNullable utility type, to exclude any null or undefined values from a union type.

// ✅
type ContentKind = NonNullable<Parameters<typeof getContent>[0]>
// ContentKind
Enter fullscreen mode Exit fullscreen mode

Now our ContentKind type perfectly matches the ContentKind in this package that was not being exported, and we can use it in our processContent function like so:

import { getContent } from '@builder.io'

const content = await getContent()

type Content = Awaited<ReturnType<typeof getContent>>
type ContentKind = NonNullable<Parameters<typeof getContent>[0]>

// 🥳
function processContent(content: Content, kind: ContentKind) {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Utility Types with React

Utility types can also help us a lot with our React components.

For instance, below I have a simplistic component to edit calendar events, where we maintain an event object in state and modify the event title on change.

Can you spot the state bug in this code?

import React, { useState } from 'react'

type Event = { title: string, date: Date, attendees: string[] }

// 🚩
export function EditEvent() {
  const [event, setEvent] = useState<Event>()
  return (
    <input 
      placeholder="Event title"
      value={event.title} 
      onChange={e => {
        event.title = e.target.value
      }}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

Doh, we are mutating the event object directly.

This will cause our input to not work as expected because React will not be aware of the state change and subsequently not rerender.

// 🚩
event.title = e.target.value
Enter fullscreen mode Exit fullscreen mode

What we need to be doing is calling setEvent with a new object.

But wait, why didn’t TypeScript catch that?

Well, technically you can mutate objects with useState. You just basically never should. We can improve our type safety here by using the Readonly utility type, to enforce that we should not be mutating any properties on this object:

// ✅
const [event, setEvent] = useState<Readonly<Event>>()
Enter fullscreen mode Exit fullscreen mode

Now our prior bug will be caught for us automatically, woo!

export function EditEvent() {
  const [event, setEvent] = useState<Readonly<Event>>()
  return (
    <input 
      placeholder="Event title"
      value={event.title} 
      onChange={e => {
        event.title = e.target.value
        //   ^^^^^ Error: Cannot assign to 'title' because it is a read-only property
      }}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

Now, when we update our code to copy the event as needed, TypeScript is happy again:

<input
  placeholder="Event title"
  value={event.title} 
  onChange={e => {
    // ✅
    setState({ ...event, title: e.target.value })
  }}
/>
Enter fullscreen mode Exit fullscreen mode

But, there is still a problem with this. Readonly only applies to top level properties of the object. We can still mutate nested properties and arrays without errors:

export function EditEvent() {
  const [event, setEvent] = useState<Readonly<Event>>()
  // ...

  // 🚩 No warnings from TypeScript, even though this is a bug
  event.attendees.push('foo')
}
Enter fullscreen mode Exit fullscreen mode

But, now that we are aware of Readonly, we can combine that with its sibling ArrayReadonly, and a little bit of magic, and make our own DeepReadonly type like so:

export type DeepReadonly<T> =
  T extends Primitive ? T :
  T extends Array<infer U> ? DeepReadonlyArray<U> :
  DeepReadonlyObject<T>

type Primitive = 
  string | number | boolean | undefined | null

interface DeepReadonlyArray<T> 
  extends ReadonlyArray<DeepReadonly<T>> {}

type DeepReadonlyObject<T> = {
  readonly [P in keyof T]: DeepReadonly<T[P]>
}
Enter fullscreen mode Exit fullscreen mode

Thanks to Dean Merchant for the above code snippet.

Now, using DeepReadonly, we cannot mutate anything in the entire tree, preventing a whole range of bugs that could occur.

export function EditEvent() {
  const [event, setEvent] = useState<DeepReadonly<Event>>()
  // ...

  event.attendees.push('foo')
  //             ^^^^ Error!
}
Enter fullscreen mode Exit fullscreen mode

Which will only pass type check if treated properly immutably:

export function EditEvent() {
  const [event, setEvent] = useState<DeepReadonly<Event>>()

  // ...

  // ✅
  setEvent({
    ...event,
    title: e.target.value,
    attendees: [...event.attendees, 'foo']
  })
}
Enter fullscreen mode Exit fullscreen mode

One additional pattern you may want to use for this kind of complexity is to move this logic to a custom hook, which we can do like so:

function useEvent() {
  const [event, setEvent] = useState<DeepReadonly<Event>>()
  function updateEvent(newEvent: Event) {
    setEvent({ ...event, newEvent })
  }
  return [event, updateEvent] as const
}

export function EditEvent() {
  const [event, updateEvent] = useEvent()
  return (
    <input 
      placeholder="Event title"
      value={event.title} 
      onChange={e => {
        updateEvent({ title: e.target.value })
      }}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

This allows us to simply provide the properties that have changed and the copying can be managed automatically for a nice DX and safety guarantees.

But we have a new problem. updateEvent expects the full event object, but what we intend is to only have a partial object, so we get the following error:

updateEvent({ title: e.target.value })
// 🚩       ^^^^^^^^^^^^^^^^^^^^^^^^^ Error: Type '{ title: string; }' is missing the following properties from type 'Event': date, attendees
Enter fullscreen mode Exit fullscreen mode

Fortunately, this is easily solved with the Partial utility type, which makes all properties optional:

// ✅
function updateEvent(newEvent: Partial<Event>) { /* ... */ }
// ...
// All clear!
updateEvent({ title: e.target.value })
Enter fullscreen mode Exit fullscreen mode

Alongside Partial, it’s worth also knowing the Required utility type, which does the opposite - takes any optional properties on an object and makes them all required.

Or, if we only want certain keys to be allowed to be included in our updateEvent function, we could use the Pick utility type to specify the allowed keys with a union:

function updateEvent(newEvent: Pick<Event, 'title' | 'date'>) { /* ... */ }
updateEvent({ attendees: [] })
//          ^^^^^^^^^^^^^^^^^ Error: Object literal may only specify known properties, and 'attendees' does not exist in type 'Partial<Pick<Event, "title" | "date">>'
Enter fullscreen mode Exit fullscreen mode

Or similarly, we can use Omit to omit specified keys:

function updateEvent(newEvent: Omit<Event, 'title' | 'date'>) { /* ... */ }
updateEvent({ title: 'Builder.io conf' })
// ✅        ^^^^^^^^^^^^^^^^^ Error: Object literal may only specify known properties, and 'title' does not exist in type 'Partial<Omit<Event, "title">>'
Enter fullscreen mode Exit fullscreen mode

Moar utilities

We touched on a good bit of typescript utilities here! But here is a quick overview of the remaining ones, which are all pretty useful in my opinion.

Record<KeyType, ValueType>

Easy way to create a type representing an object with arbitrary keys that have a value of a given type:

const months = Record<string, number> = {
  january: 1,
  february: 2,
  march: 3,
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Exclude<UnionType, ExcludedMembers>

Removes all members from a union that are assignable to the ExcludeMembers type.

type Months = 'january' | 'february' | 'march' | // ...
type MonthsWith31Days = Exclude<Months, 'april' | 'june' | 'september' | 'november'>
// 'january' | 'february' | 'march' | 'may' ...
Enter fullscreen mode Exit fullscreen mode

Extract<Union, Type>

Removes all members from a union that are not assignable to Type.

type Extracted = Extract<string | number, (() => void), Function>
// () => void
Enter fullscreen mode Exit fullscreen mode

ConstructorParameters<Type>

Just like Parameters, but for constructors:

class Event {
  constructor(title: string, date: Date) { /* ... */ }
}
type EventArgs = ConstructorParameters<Event>
// [string, Date]
Enter fullscreen mode Exit fullscreen mode

InstanceType<Type>

Gives you the instance type of a constructor.

class Event { ... }
type Event = InstaneType<typeof Event>
// Event
Enter fullscreen mode Exit fullscreen mode

ThisParameterType<Type>

Gives you the type of the this parameter for a function, or unknown if none is provided.

function getTitle(this: Event) { /* ... */ }
type This = ThisType<typeof getTitle>
// Event
Enter fullscreen mode Exit fullscreen mode

OmitThisParameter<Type>

Removes the this parameter from a function type.

function getTitle(this: Event) { /* ... */ }
const getTitleOfMyEvent: OmitThisParameter<typeof getTitle> = 
  getTitle.bind(myEvent)
Enter fullscreen mode Exit fullscreen mode

Conclusion

Utility types in TypesScript are useful. Use them.

About me

Hi! I'm Steve, CEO of Builder.io.

We make a way to drag + drop with your components to create pages and other CMS content on your site or app, visually.

So this:

import { BuilderComponent, registerComponent } from '@builder.io/react'
import { Hero, Products } from './my-components'

// Dynamically render compositions of your components
export function MyPage({ json }) {
  return <BuilderComponent content={json} />
}

// Use your components in the drag and drop editor
registerComponent(Hero)
registerComponent(Products)
Enter fullscreen mode Exit fullscreen mode

Gives you this:

Gif of Builder.io

Top comments (8)

Collapse
 
brense profile image
Rense Bakker

Great article! I hope everyone reads it, because it's a great way to improve as a webdev. You're doing gods work :) 👍

Collapse
 
prodbyola profile image
prodbyola

Oh ReturnType! Wished I saw this yesterday 🤣🤣🤣. God job mate.

Collapse
 
kristiyan_velkov profile image
Kristiyan Velkov

Take your TypeScript skills to new heights with "Mastering TypeScript Core Utility Types":

📖 Buy on Leanpub
📖 Buy on Amazon

Collapse
 
spas_z2 profile image
Spas Poptchev

immer is great alternative for the DeepReadonly type. It also makes it easy to mutate deeply nested state.

Collapse
 
steve8708 profile image
Steve Sewell

True, Immer is great

Collapse
 
myway profile image
MyWay • Edited

Shouldn't you pass Months to Exclude as first arg, in the example?

Edit: no problem, great article by the way!

Collapse
 
steve8708 profile image
Steve Sewell

Doh, just fixed, thank you!

Collapse
 
arsalannury profile image
ArsalanNury

Typescript has types more than C# and this is really funny and sadness 🧐🥴