Categories
TypeScript

Introduction to TypeScript Generics — Functions

Spread the love

One way to create reusable code is to create code that lets us use it with different data types as we see fit. TypeScript provides the generics construct to create reusable components where we can work with variety of types with one piece of code. This allows users to use these components and by putting in their own types. In this article, we’ll look at the many ways to define a generic function where we can set the types of parameters and the return value to avoid redefining functions that have the same logic but different parameter types and return types.

Defining Generic Functions

One basic use case for generics is for creating functions that has the same logic, but have different types for parameters and their return types. For example if we want to create an identity function, where we just return the same thing that’s passed in, we may write something like:

function echo(arg: number): number {
  return arg;
}

If we want this function to take accept more than one type as a parameter and return the same type that’s passed in, then we can turn it into a generic function by putting in a generic type marker instead of putting in the number type for the arg parameter and the return type. We denote a type variable with the T keyword. The T keyword allows us to capture the group for the generic function later. We can switch out number for T like in the following code:

function echo<T>(arg: T): T {
  return arg;
}

Then when we call the function, we can write something like:

console.log(echo<number>(1));
console.log(echo<string>('string'));
console.log(echo<boolean>(true));

Then we get the following out:

1
string
true

With the code above, we get type checking in each function call for the argument passed in and the return type. The code inside the <> denotes the type that we want the parameter and the return type that the function will accept for the parameter and the return type. We don’t have to put in the type explicitly like we did above. TypeScript is smart enough to identify the type as long as we define the generic function. For example, if we write the following code to call the echo function:

console.log(echo(1));
console.log(echo('string'));
console.log(echo(true));

The code will still compile and run and output the same things as it did above. This keeps the code shorter, but explicitly setting the type is clearer for developers and compiler may sometimes fail to identify the type, especially when the code is more complex. In this case, the type must be provided.

If we want to access specific properties of objects, then we have to be more specific with our generic types. For example, if we assume that the type for the parameter and the return type are always some kind of array, then we can specify that with the following code:

function echo<T>(arg: T[]): T[] {
  console.log(arg.length);
  return arg;
}

Equivalently, we can write that with the Array<T> generic type instead like in the following code:

function echo<T>(arg: Array<T>): Array<T> {
  console.log(arg.length);
  return arg;
}

In both pieces of code, we can log the length property of the array that we pass into the function as our argument since it’s guaranteed that what we pass in and return are always arrays. For example, we can call the new echo function like in the following code:

echo([1, 2, 3]);
echo(['a', 'b', 'c']);

Type inference would still work in the case above.

In the above examples, we have one parameter and one return value. But what if we want to pass in multiple parameters with different types and return one or more of them? We can write something like the following:

function echo<T, U>(arg: T, arg2: U): [T, U] {
  return [arg, arg2];
}

In the code above, we have 2 generic types, T and U , which represents same or different types. For example, we can use it like in the following code:

console.log(echo(1, 'a'));

In the code above, we have the first argument being a number and the second being a string, but we can also have both being the same type like we have in the following code:

console.log(echo(1, 2));

Variation of Type Markers

Another issue that we’re going to run into eventually is that we want parameters that have data types that are different from the return types. One way to solve this is to mix generic types and regular types with interfaces like we do in the following code:

interface EchoInterface {
  [prop: string]: any
}

function echo<T, U, V, W>(a: T, b: U, c: V, d: W): EchoInterface {
  return {a,b,c,d};
}

console.log(echo(1, 'a', false, {}));

Then we get the following from the console.log :

{a: 1, b: "a", c: false, d: {}}

We can also leave out the return type like we do in the following code:

function echo<T, U, V, W, X>(a: T, b: U, c: V, d: W) {
  return { a, b, c, d };
}

console.log(echo(1, 'a', false, {}));

We get the same output as we do above. Note that in the 2 examples above, we can specify as many letters as we want as generic type markers. Generic type markers doesn’t have to be one letter. It’s just convention for it to be a single letter. We can write something like the following:

function echo<T, U, V, W, AA>(a: T, b: U, c: V, d: AA) {
  return { a, b, c, d };
}

Another way to define a generic function is to put the signature in the interface along with the generic type markers there. Then when declare the function by assigning it to a variable, we can set the type of the variable with the interface and then assign the generic function to it with the usual generic type markers added to the function. For example, we can write something like the following code:

interface EchoFn {
  <T>(a: T): T;
};

function echo<T>(a: T): T {
  return a;
}

const e: EchoFn = echo;

We may also add the generic type marker inside the <> to the interface declaration like in the following code:

interface EchoFn<T> {
  <T>(a: T): T;
};

Then when we declare the variable that we assign the function to, we have to specify the type of the interface explicitly, like in the following code:

interface EchoFn<T> {
  <T>(a: T): T;
};

function echo<T>(a: T): T {
  return a;
}

const e: EchoFn<number> = echo;

In the code above, we have to add the number type declaration explicitly in the last line instead of letting TypeScript infer it automatically.

To avoid redefining functions that have the same logic but different parameter types and return types, we can define generic functions. To do this, we add generic type markers to our functions, and in interfaces that are used for typing the functions. We define functions by inserting generic type markers inside the <> after the function name, with each marker separated by commas, and also after the colon in parameters, and after the colon and before the open curly bracket. The generic type marker for the return type is optional. We can leave it out if we want to return something that has a different type than what we pass into the parameters. Likewise, in interfaces, we put the signature of the function in the interface and optionally in the interface definition to add enforce generic type markers in the functions we define.

By John Au-Yeung

Web developer specializing in React, Vue, and front end development.

Leave a Reply

Your email address will not be published. Required fields are marked *