Back

TypeScript - Generic Types

Dominic Sherman
Dominic Sherman
March 30, 2022
TypeScript - Generic Types

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)

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 <code 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 🥳

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 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 , and set the value of our type to be whatever the value of the generic type would be, except exclude . If we look at TypeScript’s documentation on mapped types, we can see that the syntax for that looks like this:

[object Object]

}, {} as ObjectWithoutNulls); }

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

[object Object],

const value = withNulls['foo'];

If we look at the type of , we can see that it can be , as expected:

When we run this through our function, we can see that a value off of the object after removing nulls is just 🚀

[object Object]

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

[object Object]

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:

[object Object]

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:

[object Object]

To fix the constraint, we’ll want to allow as another value passed to the function/type:

[object Object]

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

[object Object]

Looking at the type of , we can see that its type is still :

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 our generic constraint), and if so call our type again with our value, but still excluding nulls. That syntax looks like this:

[object Object]

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

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.

More about:

Dominic Sherman
[object Object]
Share this post

Interested in working with us?

Give us some details about your project, and our team will be in touch within a day or two.