Practical TypeScript Generics
typescriptIt'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
constpossibleNullishValues = [null, "1", "2"];consttruthyValues =possibleNullishValues .filter ((value ) => !!value );
ts
constpossibleNullishValues = [null, "1", "2"];consttruthyValues =possibleNullishValues .filter ((value ) => !!value );
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 ) => {Object is possibly 'null'.2531Object is possibly 'null'.console .log (. value length );// we have to do another check here >:(if (value ) {console .log (value .length );}});
ts
truthyValues .forEach ((value ) => {Object is possibly 'null'.2531Object is possibly 'null'.console .log (. value length );// we have to do another check here >:(if (value ) {console .log (value .length );}});
Turns out we can leverage the power of generics to make this better.
ts
typeFalsy = false | 0 | "" | null | undefined;consttruthy = <T >(value :T |Falsy ):value isT => {return !!value ;};
ts
typeFalsy = false | 0 | "" | null | undefined;consttruthy = <T >(value :T |Falsy ):value isT => {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
constpossibleNullishValues = [null, "1", "2"];consttruthyValues =possibleNullishValues .filter (truthy );truthyValues .forEach ((value ) => {console .log (value .length );});
ts
constpossibleNullishValues = [null, "1", "2"];consttruthyValues =possibleNullishValues .filter (truthy );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
typeListProps <T > = {data :T [];getDatumString : (datum :T ) => string;};constList = <T extends object>(props :ListProps <T >) => {return (<ul >{props .data .map ((datum ) => (<li >{props .getDatumString (datum )}</li >))}</ul >);};constdata = [{id : "test1",name : "test1 name" },{id : "test2",name : "test2 name" },];// example one: inferred data typeconstex1 = (<List data ={data }getDatumString ={(datum ) =>datum .name }/>);// example two: specified data typeconstex2 = (<List <{test : string }>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; }'.data ={[{what : 1 }]}getDatumString ={(datum ) =>datum .test }/>);
tsx
typeListProps <T > = {data :T [];getDatumString : (datum :T ) => string;};constList = <T extends object>(props :ListProps <T >) => {return (<ul >{props .data .map ((datum ) => (<li >{props .getDatumString (datum )}</li >))}</ul >);};constdata = [{id : "test1",name : "test1 name" },{id : "test2",name : "test2 name" },];// example one: inferred data typeconstex1 = (<List data ={data }getDatumString ={(datum ) =>datum .name }/>);// example two: specified data typeconstex2 = (<List <{test : string }>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; }'.data ={[{what : 1 }]}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";typeBoxProps <T extendsElementType > = {as ?:T ;children ?:ReactNode ;};constBox = <T extendsElementType = "div">(props :BoxProps <T > &Omit <ComponentPropsWithoutRef <T >, keyofBoxProps <T >>) => {const {as , ...rest } =props ;constComponent =as || "div";return <Component {...rest } />;};// box as a linkconstlinkEl = <Box as ="a"href ="#" />;constTest = (props : {name : string }) => <div >{props .name }</div >;// box as another componentconstProperty '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>>'.testEl = <Box as ={Test } />;
tsx
import {ElementType ,ComponentPropsWithoutRef ,ReactNode } from "react";typeBoxProps <T extendsElementType > = {as ?:T ;children ?:ReactNode ;};constBox = <T extendsElementType = "div">(props :BoxProps <T > &Omit <ComponentPropsWithoutRef <T >, keyofBoxProps <T >>) => {const {as , ...rest } =props ;constComponent =as || "div";return <Component {...rest } />;};// box as a linkconstlinkEl = <Box as ="a"href ="#" />;constTest = (props : {name : string }) => <div >{props .name }</div >;// box as another componentconstProperty '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>>'.testEl = <Box as ={Test } />;
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.