Wei-Wei Wu

Practical TypeScript Generics

typescript

It's been ten years since TypeScript was first released to the public. Since then, tons of developers have gradually adopted it, both in personal projects and at work. One of the more confusing and complex aspects of TypeScript is its support for generic types. Generics can be described as "type variables". They can be used to create classes, functions, and types that make it easier to write reusable code. This blog post dives into a couple of TypeScript generic utilities and use cases that I've found invaluable.

Generic Predicate Function

Say in your application logic, you need to filter out falsy values from an array. How can you do that in a type-safe way? I'd reach for Array.prototype.filter, where you can pass in a predicate function to filter out falsy values. The implementation would be something like the following:

ts
const possibleNullishValues = [null, "1", "2"];
const possibleNullishValues: (string | null)[]
const truthyValues = possibleNullishValues.filter((value) => !!value);
const truthyValues: (string | null)[]
ts
const possibleNullishValues = [null, "1", "2"];
const possibleNullishValues: (string | null)[]
const truthyValues = possibleNullishValues.filter((value) => !!value);
const truthyValues: (string | null)[]

However, truthyValues's type is still the same as possibleNullishValues even though the values are properly truthy, which isn't what we want. If we wanted to operate on items within truthyValues, we would need to add another truthy check, which kind of defeats the purpose of doing the filter earlier.

ts
truthyValues.forEach((value) => {
console.log(value.length);
Object is possibly 'null'.2531Object is possibly 'null'.
 
// we have to do another check here >:(
if (value) {
console.log(value.length);
(parameter) value: string
}
});
ts
truthyValues.forEach((value) => {
console.log(value.length);
Object is possibly 'null'.2531Object is possibly 'null'.
 
// we have to do another check here >:(
if (value) {
console.log(value.length);
(parameter) value: string
}
});

Turns out we can leverage the power of generics to make this better.

ts
type Falsy = false | 0 | "" | null | undefined;
 
const truthy = <T>(value: T | Falsy): value is T => {
return !!value;
};
ts
type Falsy = false | 0 | "" | null | undefined;
 
const truthy = <T>(value: T | Falsy): value is T => {
return !!value;
};

First let's define a type for falsy values. Falsy values in JavaScript are false, 0, null, "", and undefined. For our predicate function truthy, we can define the parameter type as our generic input type T unioned with our Falsy type. By defining the parameter type as a union, we can extract the truthy type as the output type parameter.

Then we assert the output type is the extracted truthy type by using the is keyword which is something called a type predicate. We can use type predicates to narrow a type. In this case, we are asserting that the parameter value is of type T, the extracted truthy type.

As you can see, the body of the predicate function is the same as in the first example, the only difference is the type signature. Let's test it out.

ts
const possibleNullishValues = [null, "1", "2"];
const possibleNullishValues: (string | null)[]
const truthyValues = possibleNullishValues.filter(truthy);
const truthyValues: string[]
 
truthyValues.forEach((value) => {
console.log(value.length);
});
ts
const possibleNullishValues = [null, "1", "2"];
const possibleNullishValues: (string | null)[]
const truthyValues = possibleNullishValues.filter(truthy);
const truthyValues: string[]
 
truthyValues.forEach((value) => {
console.log(value.length);
});

With this new predicate function, truthyValues now have the correct signature that we desire. We are now able to filter falsy values out from variable-typed arrays while inferring the correct truthy type.

Generic React Component

I've been mostly working with React + TypeScript apps for the past couple of years. And one thing that always pops up is generic React components. Let's see how we can use these two technologies together.

It's worth noting that to use JSX syntax in TypeScript, you must use the file extension .tsx. There are also some weird caveats especially related to generics. The TypeScript parser has a hard time disambiguating between JSX and generic syntax. However, there is a workaround.

If we wanted to define a generic list component that can render lists of arbitrary objects, this is what it would look like.

tsx
type ListProps<T> = {
data: T[];
getDatumString: (datum: T) => string;
};
 
const List = <T extends object>(props: ListProps<T>) => {
return (
<ul>
{props.data.map((datum) => (
<li>{props.getDatumString(datum)}</li>
))}
</ul>
);
};
 
const data = [
const data: { id: string; name: string; }[]
{ id: "test1", name: "test1 name" },
{ id: "test2", name: "test2 name" },
];
 
// example one: inferred data type
const ex1 = (
<List
data={data}
getDatumString={(datum) => datum.name}
(property) getDatumString: (datum: { id: string; name: string; }) => string
/>
);
 
// example two: specified data type
const ex2 = (
<List<{ test: string }>
data={[{ what: 1 }]}
Type '{ what: number; }' is not assignable to type '{ test: string; }'. Object literal may only specify known properties, and 'what' does not exist in type '{ test: string; }'.2322Type '{ what: number; }' is not assignable to type '{ test: string; }'. Object literal may only specify known properties, and 'what' does not exist in type '{ test: string; }'.
getDatumString={(datum) => datum.test}
/>
);
tsx
type ListProps<T> = {
data: T[];
getDatumString: (datum: T) => string;
};
 
const List = <T extends object>(props: ListProps<T>) => {
return (
<ul>
{props.data.map((datum) => (
<li>{props.getDatumString(datum)}</li>
))}
</ul>
);
};
 
const data = [
const data: { id: string; name: string; }[]
{ id: "test1", name: "test1 name" },
{ id: "test2", name: "test2 name" },
];
 
// example one: inferred data type
const ex1 = (
<List
data={data}
getDatumString={(datum) => datum.name}
(property) getDatumString: (datum: { id: string; name: string; }) => string
/>
);
 
// example two: specified data type
const ex2 = (
<List<{ test: string }>
data={[{ what: 1 }]}
Type '{ what: number; }' is not assignable to type '{ test: string; }'. Object literal may only specify known properties, and 'what' does not exist in type '{ test: string; }'.2322Type '{ what: number; }' is not assignable to type '{ test: string; }'. Object literal may only specify known properties, and 'what' does not exist in type '{ test: string; }'.
getDatumString={(datum) => datum.test}
/>
);

You can see that in example one, getDatumString is typed correctly based on the inferred data type T from data without explicit annotations. Type parameters can also be specified directly on a React component inside <> brackets directly after the component identifier. In example two, if data does not match the provided type parameters, TypeScript will error.

Note that we are not using the official React typings from @types/react to annotate List. To parameterize the types, we must define functional components as generic functions without annotating them as React.FunctionalComponent.

Let's dive into a more complicated example, polymorphic React components.

tsx
import { ElementType, ComponentPropsWithoutRef, ReactNode } from "react";
 
type BoxProps<T extends ElementType> = {
as?: T;
children?: ReactNode;
};
 
const Box = <T extends ElementType = "div">(
props: BoxProps<T> & Omit<ComponentPropsWithoutRef<T>, keyof BoxProps<T>>
) => {
const { as, ...rest } = props;
 
const Component = as || "div";
 
return <Component {...rest} />;
};
 
// box as a link
const linkEl = <Box as="a" href="#" />;
(property) href?: string | undefined
 
const Test = (props: { name: string }) => <div>{props.name}</div>;
 
// box as another component
const testEl = <Box as={Test} />;
Property 'name' is missing in type '{ as: (props: { name: string; }) => Element; }' but required in type 'Omit<{ name: string; }, keyof BoxProps<T>>'.2741Property 'name' is missing in type '{ as: (props: { name: string; }) => Element; }' but required in type 'Omit<{ name: string; }, keyof BoxProps<T>>'.
tsx
import { ElementType, ComponentPropsWithoutRef, ReactNode } from "react";
 
type BoxProps<T extends ElementType> = {
as?: T;
children?: ReactNode;
};
 
const Box = <T extends ElementType = "div">(
props: BoxProps<T> & Omit<ComponentPropsWithoutRef<T>, keyof BoxProps<T>>
) => {
const { as, ...rest } = props;
 
const Component = as || "div";
 
return <Component {...rest} />;
};
 
// box as a link
const linkEl = <Box as="a" href="#" />;
(property) href?: string | undefined
 
const Test = (props: { name: string }) => <div>{props.name}</div>;
 
// box as another component
const testEl = <Box as={Test} />;
Property 'name' is missing in type '{ as: (props: { name: string; }) => Element; }' but required in type 'Omit<{ name: string; }, keyof BoxProps<T>>'.2741Property 'name' is missing in type '{ as: (props: { name: string; }) => Element; }' but required in type 'Omit<{ name: string; }, keyof BoxProps<T>>'.

We've parameterized the as prop to be of type ElementType which allows us to render the Box as any possible components or DOM elements. By using ComponentPropsWithoutRef, the Box component will also inherit any props that come from as in a type-safe way. For example, the Test component requires a name prop, and if that prop is not specified on Box when Test is passed as as, TypeScript will error.

This pattern is especially prevalent in third-party UI libraries such as chakra-ui and mui.

Conclusion

Above are a couple of examples of TypeScript generic usage that I've found to be beneficial day to day. I hope you find them helpful as well! If you're interested in how I set up my TypeScript code samples in this blog post, check out Shiki-Twoslash.