Back

TypeScript - Generic Types

Dominic ShermanWednesday, March 30, 2022
a blue square with the word generic on it

Generic types provide a way to dynamically define types within TypeScript so that you can more accurately type functions and helpers. This is a tutorial on what generic types are, how to use them, and when you might find a use case for them. This assumes you have a basic understanding of TypeScript and are looking to level up your knowledge.

Example - Removing Nulls from Arrays

Suppose you had a common problem in your application with arrays containing null values. This could be data coming from your server or somewhere else, but it can be a very annoying problem to deal with because then every time you want to use this array, you have to check every value for nullability. For example:

const res = await fetch('/foo'); const data = await res.json(); return data.map((data) => { if (data) { // do something } else { // what do I do??? } });

Most of the time, we would probably just filter out any values that are null and then return the data.

const res = await fetch('/foo'); const data = await res.json(); const dataWithoutNulls = data.filter((data) => data !== null); return dataWithoutNulls.map((data) => { // do something });

This works fine, but not something we’d want to do every time we need to fetch data. So we’d probably add a helper function for it, and maybe add it to a fetch middleware or something like that.

const removeNulls = (dataWithNulls) => { return dataWithNulls.filter((x) => x !== null); } const res = await fetch('/foo'); const data = await res.json(); const dataWithoutNulls = removeNulls(data); return dataWithoutNulls.map((data) => { // do something });

If we introduce TypeScript to this scenario, that makes things a little bit more complicated. If our requests are strongly typed, which is common and easy now with GraphQL and codegen, then we have to make sure our function to remove nulls is also properly typed. Let’s try this without adding any types to our function first. For the example, we’ll set up some data that can either be an interface type or null:

interface FooType { foo: string; bar: number; } const withNulls: (FooType | null)[] = [{ foo: "foo", bar: 1 }, null, { foo: "bar", bar: 2 }]; const value = withNulls[0];

If I try to pull a value out of my withNulls array, TypeScript knows it might be null (hovering over value shows that its type can be FooType | null):

Helper Function

Let’s add our helper function:

const removeNulls = (dataWithNulls) => { return dataWithNulls.filter((x) => x !== null); } const withoutNulls = removeNulls(withNulls); const nonNullValue = withoutNulls[0];

If we look at the type on nonNullValue, its shown as any now:

nonNullValue typed as withoutNulls[0]

Since we don’t have any types on our function, TypeScript will just default everything to any. While this “works”, it defeats the point of TypeScript because now whenever we use the function we lose all context as to what the type was. At that point, we are essentially writing JavaScript. Let’s try to add some types to our function.

For the inputs and outputs of our function, we want it to receive an array that is either the FooType or null , and then return an array that is only FooType:

const removeNulls = (dataWithNulls: (FooType | null)[]): FooType[] => { // ... }

Great, that looks right, and it looks like it worked to properly maintain the types after being run through the function (hovering over nonNullValue shows its value to be FooType rather than FooType | null:

const nonNullValue is now FooType

While our function definition works now, we’ve introduced a problem within our function itself. Thefilter function doesn’t know how the logic of our predicate is working, so it thinks even after filtering the type of the function hasn’t changed and doesn’t match what we’re saying we’re returning. Here’s the error we’re getting:

Type '(FooType | null)[]' is not assignable to type 'FooType[]'. Type 'FooType | null' is not assignable to type 'FooType'. Type 'null' is not assignable to type 'FooType'.ts(2322)

TypeScript code

In order to properly type this, we need to define a predicate type as the return value of our filter function, which looks like this:

const removeNulls = (dataWithNulls: (FooType | null)[]): FooType[] => { // predicate on callback return dataWithNulls.filter((x): x is FooType => x !== null); }

Looks like everything is working now, but as I’m sure you’ve noticed, this function only works for FooType What if we needed to do it for a different type? This is where generic types come in. Without them, we’d have to write a new function for every type that we want to use, which kind of defeats the purpose of the function.

Our goal here would be to write a utility function that allows users to consume it while using their own types. If we review TypeScript’s documentation, we can see that generics are the way to do properly do this. TypeScript allows for something called a type variable, which is a special kind of variable that that works on types rather than values. From the Hello World of Generics TypeScript docs, we can see the basic identity function usage of type variables:

function identity<Type>(arg: Type): Type { return arg; }

This type variable Type ”allows us to capture the type the user provides (e.g. number), so that we can use that information later”.

Let’s try to use a type variable in our function to maintain the type passed by the user. Our goal with our type variable would be to receive our argument as an array of our type (T) or null, and return an array of just our type (T). That would look like this:

const removeNulls = <T>(dataWithNulls: (T | null)[]): T[] => { return dataWithNulls.filter((x): x is T => x !== null); }

Since we have the argument dataWithNulls defined using our generic type variable T, we can either explicitly pass T when calling the function or let TypeScript infer it for us.

// valid const withoutNulls = removeNulls<FooType>(withNulls); // also valid // since withNulls is type (FooType | null)[], TypeScript knows that T must be equal to FooType const withoutNulls = removeNulls(withNulls);

Now that we are using our generic type, we can double check our example to make sure it still works. Looking at the type of nonNullValue from above, we can see that its type is still just FooType instead of FooType | null, except now our function works for more than just FooType 🥳 TypeScript code

Now that we have a basic understanding of generics and why they can be useful, let’s look at a more complicated example.

Example - Removing Nulls from Objects

Suppose I wanted to remove the nulls from the values of an object. This is a similar problem, but requires a much different solution. Here’s my example type:

interface BarType { [key: string]: string | number | null; }

I’d like to write a function that removes any fields that are null off of it and returns the type correctly. Our first pass might look like this:

interface BarType { [key: string]: string | number | null; } interface BarTypeWithoutNulls { [key: string]: string | number; } const removeNulls = (objectWithNulls: BarType): BarTypeWithoutNulls => { return Object.keys(objectWithNulls).reduce<BarTypeWithoutNulls>((acc, key) => { const value = objectWithNulls[key]; // only add to accumulator if value is not null if (value !== null) { acc[key] = value; } return acc; }, {}); }

The JavaScript looks correct, and the types “work”, but as we found earlier it only works for this specific type and we have to redefine our new type without nulls. In order to define a type of an object that removes the null, we’ll need our type to receive a generic type. Something like this:

type ObjectWithoutNulls<T> = { // now what?? }

Our goal here is to recreate our generic type T in this new type but make sure we are excluding the nulls. What this means is that we want our type to map over all of the keys in T, and set the value of our type to be whatever the value of the generic type would be, except exclude null. If we look at TypeScript’s documentation on mapped types, we can see that the syntax for that looks like this:

type ObjectWithoutNulls<T> = { [K in keyof T]: // get the value? }

Since we have K as a key of T, that allows us to just pull T[K] to get the type value off of T:

type ObjectWithoutNulls<T> = { [K in keyof T]: T[K]; }

All we’ve done now though is create an identity type, which gives us back whatever we give it. If we want to exclude nulls from the value on T, we need to take advantage of the Exclude type built into TypeScript:

type ObjectWithoutNulls<T> = { [K in keyof T]: Exclude<T[K], null>; }

Another thing we need to do for this type is to enforce what types can be passed to it. Going to the TypeScript documentation on generic constraints, we can use the extends keyword to achieve this. We want our type to accept any generic type that is a Record with keys that are anything or null. We can use keyof any here, which generates a type that can be any of the base types (string | number | symbol). It is important here to note the difference between any and keyof any: any will allow anything to be passed (even another object), while keyof any just breaks down to only allow string | number | symbol. The final type looks like this:

type ObjectWithoutNulls<T extends Record<string, keyof any | null>> = { [K in keyof T]: Exclude<T[K], null>; }

Now we can use our type as the return value of our helper function:

// use the same generic constraint that we use in ObjectWithoutNulls const removeNulls = <T extends Record<string, keyof any | null>>(objectWithNulls: T): ObjectWithoutNulls<T> => { // cast Object.keys(type) to be keyof T since it generates a string[] by default return (Object.keys(objectWithNulls) as (keyof T)[]).reduce((acc, key) => { const value = objectWithNulls[key]; // only add to accumulator if value is not null if (value !== null) { // value is still listed as keyof T here, so we need to exclude null now that we've checked for it acc[key] = value as Exclude<T[keyof T], null>; } return acc; }, {} as ObjectWithoutNulls<T>); }

Now let’s set up an example to test it out:

const withNulls: BarType = { foo: 'foo', bar: null } const value = withNulls['foo'];

If we look at the type of value, we can see that it can be string | number | null, as expected:

TypeScript code When we run this through our removeNulls function, we can see that a value off of the object after removing nulls is just string | number 🚀

const withoutNulls = removeNulls(withNulls); const nonNullValue = withoutNulls['foo'];

TypeScript code

We can also double check that our generic constraint works correctly and only allows objects, by trying to pass [null] to the function. We see the following error, showing that we have this properly set up:

<div class="w-embed w-script"><pre><code class="language-javascript line-numbers"><script>Argument of type 'null[]' is not assignable to parameter of type 'Record<string, string | number | symbol | Record<string, any> | null>'. Index signature for type 'string' is missing in type 'null[]'.ts(2345)

Example - Removing Nulls from Objects Recursively

The final example is one that you might not run into often but can still help you understand some more advanced TypeScript concepts. In this example, let’s take the previous example and try to make it work for nested recursive objects. Here’s our example type:

interface BarType { [key: string]: string | number | BarType | null; }

Now, a value can be either a string, number, null, or another object. We can see that because of our generic constraint on the function/type, this type is not currently allowed to be passed to our function:

<div class="w-embed w-script"><pre><code class="language-javascript line-numbers"><script>Argument of type 'BarType' is not assignable to parameter of type 'Record<string, string | number | symbol | null>'. 'string' index signatures are incompatible. Type 'string | number | BarType | null' is not assignable to type 'string | number | symbol | null'. Type 'BarType' is not assignable to type 'string | number | symbol | null'.ts(2345)

TypeScript code

To fix the constraint, we’ll want to allow Record<string, unknown> as another value passed to the function/type:

type ObjectWithoutNulls<T extends Record<string, keyof any | Record<string, unknown> | null>> = { [K in keyof T]: Exclude<T[K], null>; } // use the same generic constraint that we use in ObjectWithoutNulls const removeNulls = <T extends Record<string, keyof any | Record<string, unknown> | null>>(objectWithNulls: T): ObjectWithoutNulls<T> => { // ... }

Let’s set up a quick example to test the type of a recursive field:

const nonNullNestedValue = typeof nonNullValue === 'object' ? nonNullValue['foo'] : nonNullValue;

Looking at the type of nonNullNestedValue-, we can see that its type is still string | number | BarType | null:

TypeScript code

In order to make this new type work recursively, we’ll need to take advantage of generic constraints, recursion, and conditional types. With conditional types, we can check if a type extends another type, and decide the output based on that. So in this scenario, we want to check if the value we are looking at is another object (if it extends our generic constraint), and if so call our type again with our value, but still excluding nulls. That syntax looks like this:

<div class="w-embed w-script"><pre><code class="language-javascript line-numbers"><script>type ObjectWithoutNulls<T extends Record<string, keyof any | Record<string, unknown> | null>> = { [K in keyof T]: T[K] extends keyof any | null ? Exclude<T[K], null> : ObjectWithoutNulls<Exclude<T[K], keyof any | null>>; }

TypeScript code

It’s not the prettiest looking type, but looking at the type on our value now we can see that it works! nonNullNestedValue now shows its type as ObjectWithoutNulls<BarType>:

Once you have a solid grasp on the syntax for generic types, constraints, and conditional types, you’ll find that reading and understanding the source code for libraries written in TypeScript, like Apollo Client and React Query, will become much easier. These concepts are frequently used in those places. The ability to properly type usages of these libraries cannot be understated, as well as ensuring any utility functions that you write are properly typed.

Share this post

twitterfacebooklinkedin

Related Posts:

Interested in working with us?

Give us some details about your project, and our team will be in touch with how we can help.

Get in Touch