Front-end education for the real world. Since 2018.





Real-world uses of TypeScript’s utility types

Sam Rose

Topic: TypeScript

Hi! I’m Sam, and I write interactive blog posts about computer science. Because that doesn’t quite pay the bills, during the day I’m a senior software engineer for Budibase, an open source, low-code, self-hostable app building platform. We make heavy use of TypeScript, both on the front-end and the back-end, and in this post I’d like to walk you through some real-world use-cases we have for TypeScript’s “utility types.”

What is a utility type?permalink

Utility types are types that modify other types. You can think of them as functions, but they operate on types instead of values. It’s mind bending at first — especially if you’re coming from languages that don’t have anything similar — but we’re going to walk through a load of examples to see how they work.

TypeScript has a bunch of built-in utility types, such as Uppercase:

Code language
ts

type Name = "Alice" | "Bob"
type UppercasedName = Uppercase<Name>
// type UppercasedName = "ALICE" | "BOB"

Our Name type here only allows the strings "Alice" or "Bob". This highlights an important difference between TypeScript and other typed languages. In TypeScript, a type describes a set of values. For example, the string type describes all possible strings. number describes all numbers JavaScript can work with. "Alice" | "Bob" describes just the two strings "Alice" and "Bob". The set of values a type describes is called that type’s “value space.”

The Uppercase utility type takes a type that describes a set of strings, and converts those strings to their uppercase variants. It’s like a function that takes a type as input, and produces a new type as output.

What I’m going to do in the rest of this post is show you examples of utility types you can find in the Budibase codebase. These are real utility type use-cases that we depend on every day.

Partialpermalink

Partial takes a type and makes all of its properties optional.

Code language
ts

interface User {
  id: string
  name: string
}

type PartialUser = Partial<User>
// PartialUser = {
//   id?: string
//   name?: string
// }

According to GitHub’s code search on the budibase/budibase repo, we use Partial in 45 files. It’s the most used utility type in our codebase, and I’m going to talk about 2 ways we use it.

Patch updates

At Budibase we use Koa as our web server framework. An example handler for a route in our codebase looks like this:

Code language
ts

async function find(ctx: UserCtx<void, FindTableResponse>) {
  const tableId = ctx.params.tableId
  const table = await sdk.tables.getTable(tableId)
  ctx.body = table
}

This endpoint fetches a specific Table by its tableId.

The UserCtx type signifies that this is an authenticated request, and the types inside of UserCtx are the request and response types. The request type is void because this is a GET handler and has no request body. The response type is FindTableResponse.

For some of our endpoints that update database records, you might see a request type defined like this:

Code language
ts

interface UpdateAppRequest extends Partial<App> {}

Our handler that updates App objects allows you to write to any field in App. It would be possible to define a whole new type for this purpose, with all of the same properties as App except they’re all optional, but it’s much easier to use the Partial utility type to do it for us.

The actual endpoint is quite complicated, but you can boil it down to this:

Code language
ts

async function update(ctx: UserCtx<UpdateAppRequest, UpdateAppResponse>) {
  const appId = ctx.params.appId
  const app = await sdk.applications.getApp(appId)
  const updatedApp = { ...app, ctx.request.body }
  const savedApp = await sdk.applications.saveApp(updatedApp)
  ctx.body = savedApp
}

If you only wanted a subset of fields to be updatable, you could split your type into editable and non-editable fields.

Code language
ts

interface UserReadonly {
  id: string
  createdAt: Date
}

interface UserEditable {
  name: string
}

type User = UserReadonly & UserEditable
type UpdateUserRequest = Partial<UserEditable>
type UpdateUserResponse = User

The benefit to using Partial this way is that when you’re adding a new field to a type, you don’t need to remember to add the field in multiple locations.

Partial Record

We’re going to touch on another utility type here, the Record. A Record allows you to describe an object’s keys and values by referencing other types.

Code language
ts

type Name = "Alice" | "Bob"
const likesJazz: Record<Name, boolean> = {
  Alice: true,
  Bob: false,
}

You might be wondering why we don’t just define likesJazz like this:

Code language
ts

const likesJazz = {
  Alice: true,
  Bob: false,
}

It’s a good question, the result is identical. But what happens when you add a new string to Name?

Code language
ts

type Name = "Alice" | "Bob" | "Brett"

// Type error: Property 'Brett' is missing
const likesJazz: Record<Name, boolean> = {
  Alice: true,
  Bob: false,
}

// No error
const likesJazz = {
  Alice: true,
  Bob: false,
}

This makes Records great for specifying a 1:1 mapping between types.

But what if you’re okay with only mapping some of the values? Maybe there’s a sensible default and you only want to map a couple of exceptions to the rule.

We do this here:

Code language
ts

export type UIDatasourceType =
  | "table"
  | "view"
  | "viewV2"
  | "query"
  | "custom"
  | "link"
  | "field"
  | "jsonarray"

const friendlyNameByType: Partial<Record<UIDatasourceType, string>> = {
  viewV2: "view",
}

function friendlyName(datasourceType: UIDatasourceType): string {
  return friendlyNameByType[datasourceType] || datasourceType
}

In most cases, the string of the UIDatasourceType is fine to use, but when we introduced a new version of one of the types we didn’t want to surface that V2 suffix to users.

Why not use a plain object instead of a partial record? Isn’t the benefit of a partial record that you can ignore some of the keys?

If we did that, we’d be allowing ourselves to make mistakes. What if we’d written this?

Code language
ts

const friendlyNameByType = {
  veiwV2: "view",
}

In case you missed it, there’s a typo in the key: veiwV2 instead of viewV2. If we use a plain object, we don’t get type safety on the keys. A partial record gives us that type safety, and flexibility, to address this specific concern.

Downsides

The main problem with the standard library Partial is that it only goes one level deep. If you have an object that embeds another object within it, using Partial will do nothing to the embedded object.

Code language
ts

interface User {
  id: string
  preferences: {
    theme: string
  }
}

type PartialUser = Partial<User>
// PartialUser = {
//   id?: string
//   preferences?: {
//     theme: string
//   }
// }

Note that preferences.theme is not optional.

To solve this, we could make use of a library like ts-toolbet. The Partial type in ts-toolbelt supports “deep” partials.

Code language
ts

import { O } from 'ts-toolbelt'

type PartialUser = O.Partial<User, "deep">
// PartialUser = {
//   id?: string
//   preferences?: {
//     theme?: string
//   }
// }

Alternately, if you don’t want to bring in a new dependency for just this one type, you can create your own DeepPartial type like we did.

Code language
ts

export type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]
}

It’s outside of the scope of this article to talk about how this works, but I’ll demonstrate that it does indeed work.

Code language
ts

type DeepPartialUser = DeepPartial<User>
// DeepPartialUser = {
//   id?: string
//   preferences?: {
//     theme?: string
//   }
// }

Omitpermalink

Omit removes properties from a type.

Code language
ts

interface Planet {
  name: string
  numMoons: number
}

type MoonlessPlanet = Omit<Planet, "numMoons">
// MoonlessPlanet = {
//   name: string
// }

Unsaved records

Where we use this the most in Budibase is to create types that represent our database records, except they haven’t been saved to the database yet. In this situation, the type will not have an id property.

An example of this is in saving our ViewV2 type:

Code language
ts

export async function create(
  tableId: string,
  viewRequest: Omit<ViewV2, "id" | "version">
): Promise<ViewV2> {
  // ...
}

The version property is one that we populate internally, it’s not exposed through our API. So we omit it, as well as the id property.

The benefit to this is similar to the benefit of using Partial to describe the type used to update a database record: you can maintain a single source of truth for the shape of the type, and use utility types to modify it for specific purposes. In this case, if we add a new field to the type then our function that creates new database records of that type is updated automatically.

ReturnTypepermalink

ReturnType gets the return type of a function type.

Code language
ts

function add(a: number, b: number): number {
  return a + b
}

type AddReturn = ReturnType<typeof add>
// AddReturn = number

Compatibility across the browser and NodeJS

Budibase uses TypeScript on the front-end and the back-end. We use a monorepo structure split into 13 packages. Some of these packages are intended to run in a browser, others in NodeJS, and then a couple of them are expected to run in both environments.

One of the considerations when writing TypeScript that needs to work in multiple environments is that the types of built-in functions don’t always match. An example of this is the setTimeout function. In the browser, it returns a number. In NodeJS, it returns a Timeout instance.

We have a debounce function that we’ve written in a way that will work regardless where it is run.

Code language
ts

export const debounce = (callback: Function, minDelay = 1000) => {
  let timeout: ReturnType<typeof setTimeout>
  return async (...params: any[]) => {
    return new Promise(resolve => {
      if (timeout) {
        clearTimeout(timeout)
      }
      timeout = setTimeout(async () => {
        resolve(await callback(...params))
      }, minDelay)
    })
  }
}

Admittedly, this code isn’t the best example of a debounce function. The use of the Function type is discouraged — calling it returns any — and the any[] for the parameters of the returned function could be improved. I almost didn’t include this example for these reasons, but decided to leave it in as a nod to the reality that not all the code written commercially is going to be perfect. This works well enough. I wouldn’t say no to a PR improving it, though!

Readonlypermalink

Readonly takes a type and marks all of its properties as readonly, so that any attempt to assign to them results in a TypeScript error.

Code language
ts

interface Point {
  x: number
  y: number
}

function resetX(point: Readonly<Point>) {
  point.x = 0 // Cannot assign to 'x' because it is a read-only property.
}

Communicating intent

The way that we use this in Budibase feels more like documentation than anything else. When we want to signal to the caller of a function that we are going to return a new copy of something without modifying it, we use Readonly.

A good example of this is our ensureQueryUISet function:

Code language
ts

export function ensureQueryUISet(viewArg: Readonly<ViewV2>): ViewV2 {
  const view = cloneDeep<ViewV2>(viewArg)
  // [...] business logic that modifies `view`
  return view
}

By reading the signature of this function, you know straight away that it must copy the input if it plans to make changes to it. Then in future, if anyone comes to modify this function they will also have compile-time errors if they break that contract.

A caveat to this is that Readonly suffers from the same problem that Partial does: it only affects the first level of properties. It’s shallow, not deep.

Fortunately, ts-toolbelt has our back again:

Code language
ts

import { O } from "ts-toolbelt"

type ReadonlyViewV2 = O.Readonly<ViewV2, keyof ViewV2, "deep">

For some reason, O.Readonly takes 3 arguments where O.Partial takes 2. The 2nd argument of O.Readonly is which keys to mark as readonly, so above I’ve passed in all keys of the type we’re working with.

If you wanted to, you could create your own utility type to make this easier:

Code language
ts

import { O } from "ts-toolbelt"

type DeepReadonly<T extends object> = O.Readonly<T, keyof T, "deep">
type ReadonlyViewV2 = DeepReadonly<ViewV2>

Wrapping uppermalink

There are plenty more utility types in the standard library and in libraries like ts-toolbelt. I picked the ones we make the most use of at Budibase, but I’m confident we could be using more of them! They’re one of my favourite features of TypeScript, and I hope this post has given you some inspiration for how you could use them in your own code.

Enjoyed this article? You can support us by leaving a tip via Open Collective


Newsletter