educational0 min read

Adventures in TypeScript

Intro

Whether you love it or hate it, TypeScript is an essential part of delivering quality design system components for the web because it:

  • Helps reduce the chance of runtime errors in production
  • Improves developer experiences with IDE autocomplete
  • Strengthens component prop and implementation logic

I'm a relative noob when it comes to using it, so I wanted to use my blog as a way to document some of the nuances I've learned. This is a combination of what I've learned from Twitter, teammates, and riding the struggle bus.

Miniature yellow bus zoomed in with large colorful blurred bus in the background

Getting started

Working in a TypeScript environment means that it has been setup to compile TS into JS. TS has become more mainstream in that most modern frameworks support it out the box, including NextJS (which is what this blog site is built on).

Create React App can be a little controversial, but it also supports TypeScript.

If you're looking for something specific to setting up a design system, I highly recommend design-systems-cli which also supports TS automatically.

The basics

Now that we have our environment up and running, let's start with the basics - what the heck does it mean to write something in TypeScript?

Take this simplified example where in a function, the argument name that gets passed in could be anything. It could be a string, a number, a boolean, etc.

export function hello(name) {
  return <div>Hello {name}</div>
}

But in reality, you are expecting an output like Hello Alex vs something like Hello 123. This means we really want to restrict name to be a string.

Wouldn't it be great to know as we're developing that the value we're getting back isn't what we expect?

If name is something anything other than a string, TypeScript will throw an error, letting you know both what type of data was returned and what type of data it was expecting.

Error in VS Code with wrong data type using console.log

Not every variable has a 1:1 relationship with a type. For example, you could want to assign multiple possible types for a value. TypeScript lets us do this using unions.

Unions

Unions allow us to provide different possible types that can be returned.

For example, let's say we want to limit food options to:

  • Pizza
  • Hamburger
  • Taco

You could do something like this:

export type foodCategory = 'pizza' | 'hamburger' | 'taco'
export interface EntreeProps {
  /** The category assigned for an entree */
  category: foodCategory;
}

// This would error out
<Entree category = 'fish' />

// This would work
<Entree category = 'pizza' />

This means that the value returned for foodOption can only be one of those three options. You can think of the | to mean "or" in this sense.

What's neat is that you can convert arrays into unions fairly easily.

// types.tsx
export const sizeArray = [
  'x-small',
  'small',
  'medium',
  'large',
  'x-large'
] as const

// type Size = "x-small" | "small" | "medium" | "large" | "x-large"
export type Size = typeof sizeArray[number]

sizeArray is a simple array that can be used anywhere, like if you wanted to display them in a list, map through them for a dropdown, etc.

The as const is very important here - so don't overlook it.

Size then takes the array, and converts it into a union using the keyword typeof. This is extremely useful because what this is doing is saying that Size will return each item in my array as a possible value that can be returned.

Record

We may want to attach numbers to these size options.

In order to do this, we can use Record, which is an object type that defines keys and values.

// Referencing the types
import { Size } from './types'

const sizeMap: Record<Size, number> = {
  'x-small': 2,
  small: 4,
  medium: 6,
  large: 8,
  'x-large': 10
}

interface MyCustomComponentProps {
  /** Size of my custom component */
  size: Size
}

The first parameter is what we want to use as our key. The second parameter is the type of the value, which in our case, is a number we want to assign. Note that we could feed in another union or really anything we want here, as long as it captures the type accurately.

Pick

Sometimes, we only want to specify a selection of the options from our array. This is "safer" than specifying arbitary options because we want to make sure the options we're identifying are part of our original array.

So in our size example above, maybe we only want small, medium, and large. While we could specify a separate array and type combo, there are more efficient and safer ways to do this.

During my initial research on how to do this, Pick was recommended to me as a way of doing this.

The first part of Pick assumes you have an object. Our SizeMap example is structured in a way that supports this, in that we have a list of key-value pairs. The options we want to support could then be fed in as a subset of keys, so this works as we would expect:

type CoreSize = Pick<typeof SizeMap, 'small' | 'medium' | 'large'>

While this technically works, specifing the options we want as a union is less than ideal because we can't use this as an array easily. It's better practice to have this as an array and convert this into a union.

Instead, what we can do is create a new array of our subset of options, similar to how we did it before:

// Returns type {small:number; medium:number;}'
const coreSizeArray = ['small', 'medium'] as const
type CoreSize = Pick<typeof SizeMap, typeof coreSizeArray[number]>

We should now get an error if we try to define keys other than small and medium like this: 'large' does not exist in type 'TestSize' error

This is all working great because we are using the SizeMap object. But what if I don't want to use an object, and I just want to specify a few sizes from my original array?

// This does NOT work
type CoreSize = Pick<Size, typeof coreSizeArray[number]>

What gives?

Well, what's happening here is Pick is expecting keys from an object, and we just have a union Size based off of sizeArray. So what do we do now?

Extract

Extract works similar to Pick, except it works with unions instead of objects. This is great when your original type is a union - which is what Size is.

Knowing this, we can update our logic to be more generic (a union of strings without specifying separate numeric values):

// Returns type 'small' or 'medium'
const coreSizeArray = ['small', 'medium'] as const
type CoreSize = Extract<Size, typeof coreSizeArray[number]>

Omit / Exclude

We can also use the opposite of Pick and Extract, which are Omit and Exclude and works the same way, except you specify which options you want to remove from the original array. You'd want to use these depending on what type of data you're trying to specify.

For example, maybe you're wanting to only specify weekdays in an app. You could do something like this:

// types.ts
export const dayArray = [
  'Sunday',
  'Monday',
  'Tuesday',
  'Wednesday',
  'Thursday',
  'Friday',
  'Saturday'
] as const

export type Day = typeof dayArray[number]

// app.tsx
export const weekendArray = ['Saturday', 'Sunday']
export type Weekday = Exclude<Day, typeof weekendArray[number]>

Partial types

Once you have a component properly typed, you may run into an issue where you want to reference these component props within another component. This can potentially be problematic, because some props that you specified as required in the original may not be relevant in your new component.

So how do we make it possible to where we can reference the props we need from the original component?

We can use the <Partial> type, which lets us specify all properties of a given type as optional. While we could have used this in our earlier examples, it would have defeated the purpose of making our options as safe as possible.

// All props except published are required when defining an Author
interface AuthorProps {
  /** Author's first name */
  firstName: string;
  /** Author's last name */
  lastName: string;
  /** Author's biography */
  bio: string;
  /** Number of works published by Author */
  published?: number;
}

// Returns error because bio is not not defined
const FeaturedAuthor: AuthorProps {
  firstName: 'Kelly';
  lastName: 'Harrop';
}

// Works because all types are optional using Partial
const FeaturedAuthor: Partial<AuthorProps> {
  firstName: 'Kelly';
  lastName: 'Harrop';
}

Converting to TypeScript

Converting over to TS is more than just assigning types using the right syntax - it requires a deeper understanding of intent that may not have been there previously. You may also find opportunities to optimize how you store your logic so that it becomes more reusable.

I loved reading about how Stripe went through a significant year-long overhaul from Flow to TypeScript.

Wrapping up

TypeScript felt intimidating when I first started, but like all things, just takes a bit of practice and some (sometimes A LOT of) trial and error. If you're ever feeling stuck, it helps to talk out loud what you're trying to do first, and learn the corresponding glossary terms from the TS docs.

If you haven't already, I'd recommend checking out these everyday types to get familiar with additional common scenarios and applications.

And if you've had to overcome a TypeScript struggle, I strongly recommend blogging about it because it could help others. Good luck and have fun!