OpenSesame Engineering Blog

TypeScript Utility Types

by Nick Bernard, Senior Software Engineer

August 16, 2022

Tools on a workbench

Even if you’ve been writing TypeScript for a while, you might not have ran across the language’s built-in utility types. These come in handy and save you from having to manually implement some common type transformations. I’ll go over a few utility types we use often and how you can put them to work in your code.

Record

You can type an object with specific types for its keys and values using an index signature:

type Book = { [isbn: number]: string; };

With the Record type, you can represent the same object a bit more concisely:

type Book = Record<number, string>;

If syntax were the only difference, I’d prefer the index signature since the isbn key gives more detail about the intent of the type; however, the Record type is a lot more powerful!

Record starts getting really useful when you want to lock down the properties of an object. In the above example, the object’s properties can be any string, but here’s an example of using a union type of strings for the keys:

type Locale = 'en' | 'fr' | 'es';

type LanguageName = Record<Locale, string>;

const languages: LanguageName = {
  'en': 'English',
  'fr': 'French',
  'es': 'Spanish',
};

If I try to add German to this list, the compiler displays a nice error.

TypeScript compilation error

Another common use case is transforming the values of an object to a different type. Instead of maintaining a type that’s just used for the keys of other types, we can use the keyof keyword:

const languages = {
  en: 'English',
  fr: 'French',
  es: 'Spanish',
};

type SupportedLanguages = Record<keyof typeof languages, boolean>;

const appLanguages: SupportedLocales = {
  en: true,
  es: true,
};

Again, there’s a nice error if an unexpected property is used.

Partial

The Partial type makes all properties of a type optional. This is particularly useful for creating mock objects for testing. You can create a factory function that uses the desired type without needing to provide every value:

interface Profile {
  name: string;
  avatar: string;
}

const createMockProfile = ({ name: 'Mock User', avatar: 'test.jpg' }: Partial<Profile>): Profile => ({
  name,
  avatar,
});

const testProfile = createMockProfile({ name: 'Test User' }); 

console.log(testProfile); // { name: 'Test User', avatar: 'test.jpg' }

The opposite of Partial is Required—it makes all the properties of a type required.

Pick and Omit

Sometimes you just need a subset of properties from a given type. Instead of creating a new type with values that are duplicated elsewhere in the codebase, you can use Pick and Omit.

Pick takes the given properties from a type. For example, we use Pick to build custom React components that wrap other third-party components in order to simply the interface:

import Button, { ButtonProps } from '@mui/material/Button';

type CustomButtonProps = Pick<ButtonProps, 'color'>;

function CustomButton({ color }: CustomButtonProps) {
  return (
    <Button
      color={color}
      variant="contained"
  ...
}

This pattern helps enforce consistency since it limits the customization possible with props, but it also provides a simpler dev experience.

Omit works the opposite way: it filters out properties of a given type. This is useful when you want to use an existing type but overwrite one or more of its properties.

For example, maybe you have the concept of a “variant” in your design system and you need to expose it as a prop for a custom button, but the new component library you’re adopting already uses “variant.” You might try something like this:

import Button, { ButtonProps } from '@mui/material/Button';

interface CustomButtonProps extends ButtonProps {
  variant: 'success' | 'error';
}

This won’t compile because the custom variant type doesn’t satisfy the constraints from ButtonProps:

TypeScript compilation error

It’s valid to narrow the type, i.e. variant: 'contained', but you can’t overwrite the properties of other interfaces this way.

The way around this is to use Omit to first remove the property from the type and then extend that filtered interface:

import Button, { ButtonProps } from '@mui/material/Button';

interface CustomButtonProps extends Omit<ButtonProps, 'variant'> {
  variant: 'success' | 'error';
}

Both utility types can, of course, be combined and use multiple keys to make them even more expressive:

interface CustomButtonProps extends Pick<Omit<ButtonProps, 'variant' | 'size'>, 'color' | 'type'> {
  variant: 'success' | 'error';
  size: 'tiny' | 'big';
}

And Many More

Utility types can save you a lot of time and reduce complexity in your codebase. There are a lot more than what’s covered here—you can try them all out in the official TypeScript Playground. These examples just scratch the surface of what you can do with utility types and I hope they can help you improve your TypeScript.


© 2024 OpenSesame