Telerik blogs

In this article, we break down the concepts behind TypeScript generics and explain the benefit of using them to create reusability in our TypeScript code.

TypeScript generics is one piece of TypeScript that often confuses newcomers since it makes TypeScript code appear a lot more complicated than it actually is.

First, it helps to understand what generics are. Generics is a programming method/tool that exists in languages like C# and Java and is geared to help create reusable components that can work with a variety of different types. Generics make this possible by allowing the abstraction of types used in functions or variables. TypeScript adopts this pattern by allowing us to create code that can work with different types.

We’ll go through a simple example to illustrate the basics of generics. Assume we had a function called init() that received an argument and returned said argument.

const init = (arg) => {
  return arg;
};

TypeScript

For those unfamiliar with TypeScript, the highlight behind TypeScript is the capability to specify types for our variables, parameters, functions, etc. in our code. This helps make our JavaScript code more secure by ensuring types are checked during compile-time (i.e., when code is being compiled) as opposed to run-time (i.e., when the code is being run).

If there is ever an issue or mismatch in our types, TypeScript will help notify us before we get to the point of having to run our code.

Since our init() function above is expected to return what it receives, we can specify the type of the argument and the value returned by the function to be the same. As an example, we can say they would have the type number.

const init = (arg: number): number => {
  return arg;
};

If we were to provide a value that is not a number to our function, TypeScript will emit an error.

const init = (arg: number): number => {
  return arg;
};

init(5); // GOOD
init("5"); // ERROR (Argument of type 'string' is not assignable to parameter...)

If we tried to change the return type of the function, TypeScript will display a warning, and rightly so, since it infers the type of the parameter being returned from the function.

const init = (arg: number): string => {
  return arg; // ERROR (Type 'number' is not assignable to type 'string')
};

What if we wanted the init() function to be reusable for different types? One thing we could try to do is leverage Union Types where the argument type and returned type could be one of many types.

For example, we could say the function argument can accept a number or a string and return either a number or a string.

const init = (arg: number | string): number | string => {
  return arg;
};

init(5); // GOOD (arg type and return type = number)
init("5"); // GOOD (arg type and return type = string)

Though this would work in certain cases, the example above won’t be reusable in cases we don’t know the type of the argument we’ll pass in.

Another approach we could take that would work for all types is to use the any type.

const init = (arg: any): any => {
  return arg;
};

init(5); // GOOD
init("5"); // GOOD

Using any would work, but it’s far from ideal since we won’t be able to constrain what arguments the function accepts or infer what the function is to return. This is because any is a special TypeScript type that isn’t type-checked so it should only be used sparingly.

TypeScript Generics

Here is where generics and the capability of passing a type variable comes in. Just like how we’ve said init() can accept an argument, we can also say that init() can accept a type variable, or in other words a type parameter or type argument.

In TypeScript, we can pass type variables with the angle brackets syntax (<>). Here’s an example of having the init() function accept a type variable denoted with the letter T.

const init = <T>(arg: any): any => {
  return arg;
};

Just like how the value argument is available in the function, the type argument is available in the function as well. We could say that whatever type variable is passed will be the type of the argument and the return type of the function.

const init = <T>(arg: T): T => {
  return arg;
};

To reiterate what’s happening above:

  • Our init() function accepts a type variable denoted with the letter T.
  • We assign the value of that type to be the type value of the arg parameter: arg: T.
  • Lastly, we state the return type of the function to also be the same type variable passed into our function: const init = (): T => {}

If we now were to reuse the init() function for different purposes, we can dictate the type variable in the function for different parameters.

const init = <T>(arg: T): T => {
  return arg;
};

init<number>(5); // arg type and return type = number
init<string>("5"); // arg type and return type = string
init<any>({ fresh: "kicks" }); // arg type and return type = any

In the example above, TypeScript will be smart enough to recognize the value of the type variable T, without always specifying a type value (e.g., <any>). This only works in simple cases. In more complicated cases, we’ll need to ensure type variables are being passed in.

Generic Interfaces

Generics can be used extensively and aren’t only specific to functions. Assume we wanted to have our init() function create an object that has a field property with the value of arg.

const init = <T>(arg: T): T => {
  const obj = {
    field: arg,
  };
  return arg;
};

Furthermore, we can assume we wanted to type constrain the obj created to a particular Interface type. TypeScript type aliases and interfaces also accept type variables.

Interfaces and type aliases allow us to describe the types of our JavaScript objects.

// Type Alias
type InitObj = {};

// Interface
interface InitObj {}

Here’s an example of assigning an interface type to an object that contains a string property.

interface InitObj {
  field: string;
}

const obj: InitObj = {
  field: "hello world", // GOOD (field is given a string value)
};

To describe the shape of objects, either a type alias or an interface can be used with minor differences between them. We’ll resort to using an interface since the TypeScript team has historically used interfaces to describe the shape of objects.

We could create an InitObj interface above the function that sets the type of a field property to a type variable being passed in.

interface InitObj<T> {
  field: T;
}

const init = <T>(arg: T): T => {
  const obj = {
    field: arg,
  };
  return arg;
};

In the init() function, we can define the type of obj as the InitObj interface and pass the type variable along. We can also have the function then return the field property from obj to conform to the expected return type of the init() function.

interface InitObj<T> {
  field: T;
}

const init = <T>(arg: T): T => {
  const obj: InitObj<T> = {
    field: arg,
  };
  return obj.field;
};

Default Generic Values

TypeScript also allows for the capability to have default generic type values (i.e., generic parameter defaults). Here’s an example of having our init() function and InitObj interface assign a default type value of any to the type variable that can be passed in.

interface InitObj<T = any> {
  field: T;
}

const init = <T = any>(arg: T): T => {
  const obj: InitObj<T> = {
    field: arg,
  };
  return obj.field;
};

Now if a type variable isn’t defined when using the init() function and the compiler isn’t able to infer what the type variable might be, it’ll simply be set to any.

Multiple Type Variables

By convention, the letter T is often used to infer a type variable, most likely due to the fact it stands for Type. We could very well use any letter we want—U, V, etc.

In certain cases, some prefer to extrapolate the type variable name, especially if one might pass in multiple type variables. Here’s an example of the init() function being able to accept two type variables—TData and TVariables.

interface InitObj<T = any> {
  field: T;
}

const init = <TData = any, TVariables = any>(arg: TData): TData => {
  const obj: InitObj<TData> = {
    field: arg,
  };
  return obj.field;
};

To summarize what we’ve learned, let’s break the above code sample down step by step.

Outside of our function, we create an interface to describe the shape of an object that has a field property.

  • The interface accepts a type variable denoted as T and given a default value of any.
  • The interface assigns the type variable to be the type of the field property.
interface InitObj<T = any> {
  field: T;
}

We declare an init() function.

  • The function accepts two type variables—TData and TVariables. Both are given a default value of any. Since we weren’t using the TVariables interface in our function so we’ll omit it from our function.
  • We assign the TData type variable to be the type of the arg parameter and the returned value of the function.
const init = <TData = any>(arg: TData): TData => {
  // ...
};

In our init() function, we create an object with the name of obj that has a field property. The field property is given a value of the arg parameter passed into the function.

  • We specify the type of obj to be InitObj. As we specify its type, we pass the TData as a type variable to the InitObj.
const init = <TData = any>(arg: TData): TData => {
  const obj: InitObj<TData> = {
    field: arg,
  };
};

Lastly, we have our function simply return the value of the obj.field property.

interface InitObj<T = any> {
  field: T;
}

const init = <TData = any>(arg: TData): TData => {
  const obj: InitObj<TData> = {
    field: arg,
  };
  return obj.field;
};

init<number>(5); // GOOD - function returns 5
init<string>("5"); // GOOD - function returns "5"

/* 
    ERROR
    Argument of type '{ nestedField: string; }' is not assignable
    to parameter of type 'string'.
  */
init<string>({ nestedField: "5" });

// GOOD - function returns { nestedField: "5" }
init<{ nestedField: string }>({ nestedField: "5" });

Conclusion

Generics are a powerful tool in helping make TypeScript code reusable while ensuring type safety remains. For people who are new to TypeScript, the angle brackets syntax can be one of the first things that makes TypeScript code hard to read. We hope this article helps explain, in an easy and understandable manner, how generics can be implemented in TypeScript.


About the Author

Hassan Djirdeh

Hassan is a senior frontend engineer and has helped build large production applications at-scale at organizations like Doordash, Instacart and Shopify. Hassan is also a published author and course instructor where he’s helped thousands of students learn in-depth frontend engineering skills like React, Vue, TypeScript, and GraphQL.

Related Posts

Comments

Comments are disabled in preview mode.