4 TypeScript Tips To Improve Your Developer Experience That You Might Not Know
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
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:
- Add assertions (
isPrime
) at the beginning of each function. - 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 ItemsList
components, 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