DEV Community

loading...
Cover image for Keeping Your TypeScript Code DRY With Generics

Keeping Your TypeScript Code DRY With Generics

asayerio_techblog profile image OpenReplay Tech Blog Originally published at blog.asayer.io ・7 min read

by author Samaila Bala

JavaScript has become popular over the years, with the increase in popularity you have more developers migrating from other strongly typed programming languages complaining about the loosely-typed nature of JavaScript and that brings us to TypeScript.

TypeScript is an open-source programming language that is a superset of JavaScript. It builds on top of JavaScript by adding statically typed definitions. This gives developers assurance over code written as it saves time catching errors before a code is executed.

In this article, we will be looking at one of the important concepts of TypeScript called Generics and how it helps developers in following the Don’t-Repeat-Yourself (DRY) principle.

This article assumes you have a basic knowledge of TypeScript to follow along. You can brush up on the basics here. You also need the following tools below to install TypeScript on your machine:

  • Node v12 or greater
  • npm v5.2 or greater
  • A code editor
  • A Terminal

Installing TypeScript

To get started with TypeScript open a terminal and run the code below to create a directory for the project:

mkdir typescript-generics-example
Enter fullscreen mode Exit fullscreen mode

Navigate to the project directory

cd typescript-generics-example
Enter fullscreen mode Exit fullscreen mode

Run the code below to create a package.json file

npm init -y
Enter fullscreen mode Exit fullscreen mode

Install the TypeScript library by running the code below

# using npm
npm install --global typescript # Global installation
npm install --save-dev typescript # Local installation
Enter fullscreen mode Exit fullscreen mode

Create a tsconfig.json file

tsc --init 
Enter fullscreen mode Exit fullscreen mode

The command above creates a tsconfig.json file in your project directory. The tsconfig.json file specifies the root files and the compiler options required to compile the project.

To test our setup create a file app.ts in the root directory and paste the code below

function showMessage(name: string) {
  console.log('Hello ' + name);
}
showMessage('Sama');
Enter fullscreen mode Exit fullscreen mode

Now go to package.json and modify the script property to be similar to the code below

"scripts": {
  "dev": "tsc app.ts && node app.js && rm -rf app.js"
},
Enter fullscreen mode Exit fullscreen mode

The dev command transpiles TypeSript to JavaScript before executing and then removes the JavaScript file after execution.

Open a terminal, navigate to the root directory of the project and run the code below to run the script

npm run dev
Enter fullscreen mode Exit fullscreen mode

You should get "Hello Sama” as output in your terminal.

What are Generics?

Generics have been a major feature of strongly typed languages like Java and C#. In TypeScript, they allow the types of components and functions to be specified later which allows them to be used in creating reusable components that can apply to different use cases, for example:

function returnInput <Type>(arg: Type): Type {
  return arg;
};
const returnInputStr = returnInput<string>('Foo Bar');
const returnInputNum = returnInput<number>(5);

console.log(returnInputStr); // Foo Bar
console.log(returnInputNum); // 5
Enter fullscreen mode Exit fullscreen mode

In the example above, I created a generic function that returns the user input. When calling the function I made sure it is type-safe by specifying the type at the point of execution. This way the function is reusable for a different use case but also checks to make sure the type specified is returned.

The Problem

There are times when you want to create a function or component for multiple use cases like the example below:

type TodoItem = { taskId: number; task: string | number; done: boolean };

let id: number = 0;
let todoList: Array<TodoItem> = [];

function printTodos(): void {
  console.log(todoList);
}
function addTodo(item: string): void {
  todoList.push({ taskId: id++, task: item, done: false });
}
function addTodoNumber(item: number): void {
  todoList.push({ taskId: id++, task: item, done: false });
}

addTodo('Learn TypeScript');
addTodoNumber(22);
printTodos();
Enter fullscreen mode Exit fullscreen mode

In the code snippet we are trying to add two different types of todo items to the todoList array, because of the different types we have to create two different functions to add todoItemsto the todoList. This violates the Don’t-Repeat-Yourself (DRY) principle which states that "Every piece of knowledge or logic must have a single, unambiguous representation within a system". In this case, the two functions have the same implementation with just different types.

An alternative to duplication can be done by using the any type

type TodoItem = { taskId: number; task: string | number; done: boolean };

let id: number = 0;
let todoList: Array<TodoItem> = [];

function printTodos(): void {
  console.log(todoList);
}
function addTodo(item: any): void {
  todoList.push({ taskId: id++, task: item, done: false });
}

addTodo('Learn TypeScript');
addTodo(22);
printTodos();
Enter fullscreen mode Exit fullscreen mode

This makes sure we adhere to the DRY principle but introduces another problem. Using any datatype means we accept any type, which in turn means we aren’t controlling the type accepted and returned, thus invalidating the benefits of using types in our code.

In the next section, we will look at how to adhere to the DRY Principle by making sure our solution is generic and type-safe by controlling the type of data accepted and returned.

Using Generics to solve the problem

Now that we’ve established what Generics are and why they are needed let's see how we can use them to solve the problem stated in the previous section.

let id: number = 0;
let todoList = [];
function printTodos(): void {
  console.log(todoList);
}
function addTodo<Type>(item: Type): void {
  todoList.push({ taskId: id++, task: item, done: false });
}
addTodo<string>('Learn TypeScript');
addTodo<number>(22);
printTodos();
Enter fullscreen mode Exit fullscreen mode

In the solution above we’ve made the function addTodo we created earlier, Generic. The <Type> is a placeholder that will be replaced by the type when we run the function and it ensures the function is type-safe e.g addTodo<string>('Sama'); declares the type as a string so if the parameter passed to the function isn't a string it’ll result in an error.

Generic functions also allow us to pass default types to the function definition. This helps define a default behavior if we want to avoid having to declare the type every time we use the function.

function addTodo<Type = string>(item: Type): void {
  todoList.push({ taskId: id++, task: item, done: false });
}
addTodo('Learn TypeScript'); 

Enter fullscreen mode Exit fullscreen mode

So far, we’ve looked at creating generic functions with just one parameter. What if the function has multiple parameters of different types?

let id: number = 0;
let todoList = [];

function printTodos(): void {
  console.log(todoList);
}
function addTodo<T, S>(item: T, status: S): void {
  todoList.push({ taskId: id++, task: item, done: status });
}

addTodo<string, boolean>('Learn TypeScript', true);
addTodo<number, boolean>(22, false);
printTodos();
Enter fullscreen mode Exit fullscreen mode

We can solve this by passing multiple parameters as placeholders <T,S> to the generic function definition and then specifying what type belongs to an argument (item: T, status: S). Lastly, when we run the function we can now replace the placeholders defined with the correct types addTodo<string, boolean>('Learn TypeScript', true).

Generic Classes

We can also make classes Generic. Let's create a Todo class to show how this works

let id: number = 0;
type TodoListItem = {
  taskId: number;
  task: string;
  done: boolean;
}
class Todo<Type> {
  _todoList: Array<Type> = [];
  addTodo(item: Type): void {
    this._todoList.push(item);
  }
  printTodos(): void {
    console.log(this._todoList);
  }
}
const Todos = new Todo<TodoListItem>();
Todos.addTodo({ taskId: id++, task: 'learn TypeScript', done: true });
Todos.addTodo({ taskId: id++, task: 'Practice TypeScript', done: false });
Todos.printTodos();
Enter fullscreen mode Exit fullscreen mode

Generic classes are similar to Generic functions the major difference is in how they are used. Generic classes take a type parameter when they are instantiated new Todo<TodoItem>(); while Generic functions take a Type parameter at the point of execution:addTodo<string>('Sama').

Generic Constraints

While Generic functions and classes make sure we adhere to the DRY principle it also gives rise to some questions we will look at below:

  1. What if we want to limit a Generic function to certain types?

When a function is Generic it means it can be used in any form and also accept any type. For example

addTodo<boolean>(true);
Enter fullscreen mode Exit fullscreen mode

But what if we want to use the generic function for strings and numbers but not boolean. How do we go about it?

To do so we’ll use the extends keyword and specify the types we want to constrain the function to as specified in the example below

function addTodo<Type extends string | number>(item: Type): void {
  todoList.push({ taskId: id++, task: item, done: false });
}
Enter fullscreen mode Exit fullscreen mode

From the solution above we’ve made sure this generic function will only accept types of number and string and nothing else. This means when you try to execute the boolean example showed it’ll result in an error as boolean wasn’t part of the type constraint.

  1. What if our function implementation doesn't support a particular Type?

Let's imagine we have a function that returns the length of the todoList

function getTodoListLength<Type>(arr: Type): void {
  console.log(arr.length);
}
Enter fullscreen mode Exit fullscreen mode

If we try to execute this function with types that don’t have support for the length attribute (like number) it will result in an error.

To solve this problem we can also make use of the extends keyword to constrain the types to what the implementation supports as shown below

function getTodoListLength<Type extends Array<TodoItem>>(arr: Type): void {
  console.log(arr.length);
}
getTodoListLength<Array<TodoItem>>(todoList);
Enter fullscreen mode Exit fullscreen mode

Measuring front-end performance

Monitoring the performance of a web application in production may be challenging and time-consuming. Asayer is a frontend monitoring tool that replays everything your users do and shows how your app behaves for every issue. It’s like having your browser’s inspector open while looking over your user’s shoulder.

Asayer lets you reproduce issues, aggregate JS errors and monitor your app’s performance. Asayer offers plugins for capturing the state of your Redux or VueX store and for inspecting Fetch requests and GraphQL queries.

Asayer Redux

Happy debugging, for modern frontend teams - Start monitoring your web app for free.

Conclusion

In this article, we’ve looked at how to use TypeScript Generics to write reusable functions and classes that respect the DRY principle. We’ve also looked at how to get around Generic constraints by using the extends keyword.
While Generics might be difficult to understand the aim of this article was to make it less complex by using relatable examples so you’ll be able to practice and apply them in your applications.

Discussion (0)

Forem Open with the Forem app