Contents

4 TypeScript Tips To Improve Your Developer Experience That You Might Not Know

Mieszko Sabo

05 Jun 2023.11 minutes read

4 TypeScript Tips To Improve Your Developer Experience That You Might Not Know webp image

TypeScript is more than just annotating the types of function arguments. In this blog post, we'll look at some interesting use cases for less common TypeScript techniques and features, such as type-level programming, generics, or branded types.

1. Derive new types instead of copy-pasting where possible

Let’s say that our app deals with transactions and supports 3 currencies. We could try to create a union type of these currencies to help us better type our utility functions:

type Currency = "EUR" | "USD" | "GBP";

function currencyToSymbol(currency: Currency): string {
    switch (currency) {
      case "EUR":
        return "€";
      case "USD":
        return "$";
      case "GBP":
        return "£";
    }
  }

So far, so good. When using this type, we benefit from nice autocompletion and TypeScript ensures we handle all possible cases. The problem arises when we want to implement a UI element that allows the user to select a currency:

const CurrencySelector = () => (
    <select>
      {["EUR", "USD", "GBP"].map((currency) => (
        <option value={currency}>{currency}</option>
      ))}
    </select>
  );

This is not ideal. We could define this tuple outside of the component and type it as Currency[], but that won’t save us from manually keeping it in sync with the Currency type when it changes. We can solve all of these problems by simply deriving the Currency type from the constant tuple:

const currencies = ["EUR", "USD", "PLN"] as const;
type Currency = typeof currencies[number];
//   ^ "EUR" | "USD" | "PLN"

Type-level programming

Ok, time for something more challenging.

Suppose an API returns a response of the following type:

type RawResponse = {
  last_edit: string;
  items: {
    content: string;
    column_idx: number;
    selected_color: string;
  }[];
}

However, since we are using camelCase in our app, we map this response to a new object with appropriate field names:

type Response = {
  lastEdit: string;
  items: {
    content: string;
    columnIdx: number;
    selectedColor: string;
  }[];
}

const mapResponseToCamelCase = (resp: RawResponse): Response => {
// ...
}

This approach has many drawbacks. The most obvious is that we have to do the tedious task of converting and writing this type manually, and that's not what programmers do. We could ask some AI to do it for us, but that would leave us with another problem: redundancy. If the API changes, we now have to remember to update the type in two places.

We could use TypeScript's advanced support for type-level programming to remedy all this. We'll write a type that takes any type in snake case and transforms it into an equivalent type in camel case:

type SnakeToCamel<T> = T extends `${infer part}_${infer Rest}`
    ? `${part}${Capitalize<SnakeToCamel<Rest>>}`
    : T;

type example1 = SnakeToCamel<"hello">; // "hello"
type example2 = SnakeToCamel<"hello_world">; // "helloWorld"

type DeepSnakeToCamel<T> = T extends any[]
    ? { [K in keyof T]: DeepSnakeToCamel<T[K]> }
    : {
        [K in keyof T as SnakeToCamel<K>]: DeepSnakeToCamel<
          T[K]
        >;
      };

type example3 = DeepSnakeToCamel<{
    foo: { foo_bar: string; foo_bar_baz?: { bar: string } };
  }>;
/*   ^
 {
    foo: {
        fooBar: string;
        fooBarBaz?: {
            bar: string;
        };
    };
}
*/

Now, we can keep our RawResponse as a single source of truth and derive other types from it:

type Response = DeepSnakeToCamel<RawResponse>;

const mapResponseToCamelCase = (resp: RawResponse): Response = {
// ...
}

if the code above blew your mind – don’t worry, I’ve got you covered! Check out my other article where I introduce all concepts needed for writing such complex types.

Using TypeScript’s built-in types

Here are some other built-in generic types that are useful for deriving other types:

  • Awaited – recursively unwraps Promises e.g. Awaited<Promise<string>> -> string
type unwrapped = Awaited<Promise<string>>;
//   ^ string
  • Pick and Omit – picks or omits a set of properties from another type, e.g.
type BlogPost = {
  title: string;
  author: Author;
  content: string;
}

type BlogPostPreview = Pick<BlogPost, "title" | "author">
//   ^ { title: string; author: Author; }
// same as:
type BlogPostPreview2 = Omit<BlogPost, "content">;
type A = Exclude<"a" | "b" | "c", "a" | "b">;
//   ^ "c"

type B = Extract<"a" | "b" | "c", "a" | "f">;
//   ^ "a"
  • Parameters and ReturnValue – extracts the type of, accordingly, function parameters or its return value, e.g.:
type params = Parameters<(a: string, b: number) => void>;
//   ^ [string, number]

type retVal = ReturnValue<(a: string, b: number) => void>;
//   ^ void

2. Generate types directly from your backend

How do we ensure that communication with external REST APIs is type-safe?

In small projects, or if the REST API is simple and never changes, we can type our fetch functions ourselves. However, if you are working on an actively developed project, it would be tedious to manually update the API types based on change logs, or worse, slack messages from your colleagues.

One solution is code generation. A common approach is as follows:

  • The backend team provides us with an OpenAPI schema as a JSON or Yaml file. This file is usually generated from their source code.
  • The frontend team uses a tool like openapi-typescript or dtsgenerator to regenerate the types.
  • Finally, they run a type check to see if any breaking changes need to be addressed.

Generating fully typed React hooks

If you’re using React and react-query, then you can go one step further and generate not just types but also fully-typed react-query hooks! 🤯 I can recommend Fabien Bernard’s openapi-codegen for this:

import { useListPets } from "./petStore/petStoreComponents"; // <- output from openapi-codegen

const Example = () => {
  const { data, loading, error } = useListPets();

  // `data` is fully typed and have all documentation from OpenAPI
};

You can read more about it in his blog post.

Of course, the quality of the OpenAPI schema makes all the difference. If the schema uses only broad types (e.g., string instead of “option1” | “option2”), or doesn't distinguish between required and optional fields, it undermines most of the benefits.

3. Branded types

Sometimes in our applications, we deal with values that are unique subsets of some built-in primitive type. For example, emails, urls, or certain ID formats are subsets of the string type, and odd numbers, integers, or primes are subsets of the number type. While the TypeScript type system is expressive, even it has its limits :)

The problem

Suppose we're writing a program that deals with prime numbers. For readability, we've defined a bunch of little helper functions that take a prime as an argument and do some calculations with it. Still from the overall perspective of the program, we're reading a single prime and then passing it through these functions.

const doStuffWithPrime1 = (prime: number) => { /* ... */ }
const doStuffWithPrime2 = (prime: number) => { /* ... */ }
const doStuffWithPrime3 = (prime: number) => { /* ... */ }

const main = (arg: number) => {
    const res1 = doStuffWithPrime1(arg);
    const res2 = doStuffWithPrime2(arg);
    const res3 = doStuffWithPrime3(arg);

    console.log("results: ", res1, res2, res3);
}

We can't just use types to force the argument to be a real prime. I can think of two simple solutions:

  1. Add assertions (isPrime) at the beginning of each function.
  2. Assert that arg is prime once in main and make the helper functions “trust” that they will get a prime number.

The first option has the advantage that it keeps our program safe no matter how we change our code around these helper functions. It also makes our helper functions pure and reusable since their correctness doesn't depend on some assumption that can't be checked at compile time.

The second approach, however, saves us from making redundant assertions, which in our case, can be quite computationally expensive.

Well, what if I told you that there is a less common technique in TypeScript that allows us to combine the benefits of both of the above approaches? 😊

Solving the problem with branded types

In TypeScript, if you define a Prime type with type Prime = number it is just an alias, indistinguishable from the type on the right. So you can use Prime instead of any number, and vice versa.

type Prime = number;

declare function fun(p: Prime): void;

// this should error but it doesn't
const someNum: number = 2;
fun(someNum); 
fun(42);

However by writing:

type Prime = number & { readonly _brand: unique symbol };

we create a special type that is bound to that particular Prime type identifier.

type Prime = number & { readonly _brand: unique symbol };

declare function fun(p: Prime): void;

const someNum: number = 2;
fun(someNum); // this results in TS error 🥳
fun(42); // this as well

Now, we can create an assertion function that checks that a given number is a prime and reflects it at the type level:

function assertIsPrime (num: number): asserts num is Prime {
    /* put real implementation here... */
    if (num !== 2) {
        throw new Error("the number is not prime");
    }
}

const someNum: number = 2;

assertIsPrime(someNum); // after this line someNum will be inferred to be `Prime`

fun(someNum); // it type checks!

fun(2); // this errors, because `2` is inferred as number so it can't be passed
        // to a function that requires an argument of `Prime` type 👍🏻

We can rewrite our original example to use branded types:

type Prime = number & { readonly _brand: unique symbol };

function assertIsPrime (num: number): asserts num is Prime {
    /* put real implementation here... */
    if (num !== 2) {
        throw new Error("the number is not prime");
    }
}

const doStuffWithPrime1 = (prime: Prime) => { /* ... */ }
const doStuffWithPrime2 = (prime: Prime) => { /* ... */ }
const doStuffWithPrime3 = (prime: Prime) => { /* ... */ }

const main = (arg: number) => {
    assertIsPrime(arg);
    const res1 = doStuffWithPrime1(arg);
    const res2 = doStuffWithPrime2(arg);
    const res3 = doStuffWithPrime3(arg);

    console.log("results: ", res1, res2, res3);
}

4. Use generics in React components

Let’s say we have these two types in our app:

type User = {
  id: string;
  name: string;
  role: 'admin' | 'client';
  joinedAt: Date;
};

type Item = {
  id: string;
  name: string;
  size: 'small' | 'medium' | 'large';
};

There are places in our app where we want to display a list of users, and there are places where we want to display a list of items. We could create UsersList and ItemsListcomponents, but the logic and appearance of both lists are the same, so we might want to do something different. One idea would be to create a single component that accepts anything with a name and id:

type ListProps = {
  options: { name: string; id: string }[];
  onChange: (x: { name: string; id: string }) => void;
};

const List = ({ options, onChange }: ListProps) => {
  return (
    <select
      onChange={(e) => onChange(options.find((el) => el.id === e.target.value))}
    >
      {options.map((el) => (
        <option value={el.id}>{el.name}</option>
      ))}
    </select>
  );
};

Since both types have these two fields, they can be passed to the List component, which seems to work.

const users: User[] = [
  {
    id: 'aaa',
    name: 'John',
    role: 'admin',
    joinedAt: new Date(2023, 5, 7),
  },
  {
    id: 'bbb',
    name: 'Mark',
    role: 'client',
    joinedAt: new Date(2023, 5, 7),
  },
  {
    id: 'ccc',
    name: 'Matt',
    role: 'client',
    joinedAt: new Date(2023, 5, 7),
  },
];

const items: Item[] = [
  {
    id: 'ddd',
    name: 'Table',
    size: 'small',
  },
  {
    id: 'eee',
    name: 'Chair',
    size: 'medium',
  },
  {
    id: 'fff',
    name: 'Bed',
    size: 'large',
  },
];

export default function App() {
  return (
    <div>
      <h1>Hello</h1>
      <List options={users} onChange={(x) => console.log(x)} />
      <List options={items} onChange={(x) => console.log(x)} />
    </div>
  );
}

Unfortunately, we notice a problem. Whatever we give to our component, its onChange argument will always be typed as { id: string; name: string; } even though we know what type it really is.

In such situations, programmers can use the as to tell the compiler, “I know more than you”. However, this is more verbose, and if we change our implementation in the future, we may run into a runtime error.

export default function App() {
  return (
    <div>
      <h1>Hello</h1>
      <List options={users} onChange={(x) => someOperationOnUser(x as User)} />
      <List options={items} onChange={(x) => someOperationOnItem(x as Item)} />
    </div>
  );
}

Fortunately, we can use TypeScript's generics to make this component behave the way we want.

First, we declare a type variable T, which can be any type as long as it extends the { id: string; name: string } shape, i.e., it has at least these two fields but can have more.

const GenericList = <T extends { id: string; name: string }>(...) => {
  ...
};

Next, we pass T to GenericListsProps in order to tie the options prop type with onChange. This way, we say that whatever the type T is, options is an array of that type, and onChange is a function from T to void.

type GenericListProps<T> = {
  options: T[];
  onChange: (x: T) => void;
};

const GenericList = <T extends { id: string; name: string }>({
  options,
  onChange,
}: GenericListProps<T>) => {
    ...
};

The body of the component remains the same. Now let's use this component in our app and see how the types are inferred!

type GenericListProps<T> = {
  options: T[];
  onChange: (x: T) => void;
};

const GenericList = <T extends { id: string; name: string }>({
  options,
  onChange,
}: GenericListProps<T>) => {
  return (
    <select
      onChange={(e) => onChange(options.find((el) => el.id === e.target.value))}
    >
      {options.map((el) => (
        <option value={el.id}>{el.name}</option>
      ))}
    </select>
  );
};

Thanks for reading! Did you find any of these techniques particularly interesting? Do you use them in your day-to-day programming? Let us know, and subscribe to our blog for more front-end related posts in the future.

Technical review: Marcin Baraniecki

Blog Comments powered by Disqus.