Are you ever building something in TypeScript and realize...
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>
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>>
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]
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]
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
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) {
// ...
}
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
}}
/>
)
}
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
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>>()
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
}}
/>
)
}
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 })
}}
/>
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')
}
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]>
}
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!
}
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']
})
}
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 })
}}
/>
)
}
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
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 })
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">>'
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">>'
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,
// ...
}
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' ...
Extract<Union, Type>
Removes all members from a union that are not assignable to Type
.
type Extracted = Extract<string | number, (() => void), Function>
// () => void
ConstructorParameters<Type>
Just like Parameters, but for constructors:
class Event {
constructor(title: string, date: Date) { /* ... */ }
}
type EventArgs = ConstructorParameters<Event>
// [string, Date]
InstanceType<Type>
Gives you the instance type of a constructor.
class Event { ... }
type Event = InstaneType<typeof Event>
// Event
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
OmitThisParameter<Type>
Removes the this
parameter from a function type.
function getTitle(this: Event) { /* ... */ }
const getTitleOfMyEvent: OmitThisParameter<typeof getTitle> =
getTitle.bind(myEvent)
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)
Gives you this:
Top comments (8)
Great article! I hope everyone reads it, because it's a great way to improve as a webdev. You're doing gods work :) 👍
Oh ReturnType! Wished I saw this yesterday 🤣🤣🤣. God job mate.
Take your TypeScript skills to new heights with "Mastering TypeScript Core Utility Types":
📖 Buy on Leanpub
📖 Buy on Amazon
immer is great alternative for the
DeepReadonly
type. It also makes it easy to mutate deeply nested state.True, Immer is great
Shouldn't you pass Months to Exclude as first arg, in the example?
Edit: no problem, great article by the way!
Doh, just fixed, thank you!
Typescript has types more than C# and this is really funny and sadness 🧐🥴