Thinking

Typescript generics, what, how, why?

Want to know how you and your development team can mitigate errors, reduce repeated code and produce a maintainable codebase? With the help of typescript generics, you can do just that.

Laptop on a desk

Photo by Clément H

What

Typescript generics allow you to create reusable and flexible types, classes and functions. You can write strongly typed code that is flexible enough to be used with many defined types, and even complex type structures.

How

Let’s start off easy. You can use a generic to define a return type for a function.

/**
 * Define <T> as your generic
 * Use T as the return type for the function
 */
const mockFunction = <T>(data): T => data;
const isString = mockFunction<string>('');
const isBoolean = mockFunction<boolean>('');

I did say it was easy! Above, we are defining what the expected return type is. Although you may have noticed that ‘isBoolean’, although it’s actually a string, we have just told typescript it’s a boolean. So what about the function argument? We can use the generic here too.

/**
 * Define <T> as your generic
 * Use T as the argument type
 */
export const mockFunction = <T>(data: T) => data;
const isString = mockFunction('');
const isBoolean = mockFunction(true);

Now you’ll notice that we no longer need to define our generic when executing the function. Instead, typescript grabs the type from the argument. As we’ve returned the argument, isString and isBoolean are now accurate.

You can also define multiple generics.

/**
 * Define multiple generics, <T, U>
 * Use generics for both arguments
 */
export const mockFunction = <T, U>(arg1: T, arg2: U) => ({ arg1, arg2 });
const { arg1: isString, arg2: isBoolean } = mockFunction('', true);

And you can take it a step further and create generic interfaces.

/**
 * Define interface which accepts generics
 * And assigns them to it's properties
 */
export interface Interface<T, U> {
  arg1: T;
  arg2: U;
}
​
/**
 * Define the generics as normal for the function
 * Pass them to the interface
 */
export const mockFunction = <T, U>(data: Interface<T, U>) => data;
const { arg1: isString, arg2: isBoolean } = mockFunction({
  arg1: '',
  arg2: true,
});

Pretty flexible right? Generics open a world of type safe possibilities. Here’s an example of a more complex usage we use here PRISM. We have multiple types of JWT token types, each of which require different data structures. When we generate these tokens, we want typescript to tell us what data is required. We could write these data types individually and use generics like we have above, or we can get typescript to do some more heavy lifting for us.

/**
 * Define a token type enum
 */
export enum TokenType {
  TYPE1 = 'TYPE1',
  TYPE2 = 'TYPE2',
  TYPE3 = 'TYPE3',
}
​
/**
 * Define the required data for each TokenType using a generic
 * `extends TokenType` requires the generic (T) to be of type TokenType
 * If we do not define any data, it will fallback to null
 */
export type TokenData<T extends TokenType> =
  T extends TokenType.TYPE1 ? {requiredString: string} :
  T extends TokenType.TYPE2 ? {requiredBoolean: boolean} :
  null;
​
/**
 * Define the argument for the function to generate the token
 * This as well accepts a TokenType generic
 * The TokenType generic is used for the type
 * This is where we tell typescript what TokenType we are using
 * Then we say for the data, we want TokenData, using the given TokenType
 */
export interface TokenArgs<T extends TokenType> {
  type: T;
  data: TokenData<T>;
}
​
/**
 * Define the generateToken function, define the args
 */
export const generateToken = <T extends TokenType>(data: TokenArgs<T>) => data;
​
/**
 * When we use TokenType.TYPE1
 * data.requiredString is required
 */
const thisTokenRequiresString = generateToken({
  type: TokenType.TYPE1,
  data: {
    requiredString: '',
  },
});
​
/**
 * When we use TokenType.TYPE2
 * data.requiredBoolean is required
 */
const thisTokenRequiresBoolean = generateToken({
  type: TokenType.TYPE2,
  data: {
    requiredBoolean: true,
  },
});
​
/**
 * When we use TokenType.TYPE3
 * no data is required, as we have not defined any in TokenData
 */
const thisTokenDoesNotRequireData = generateToken({
  type: TokenType.TYPE3,
});

Here we have used generics to create an incredibly easy to use function. As typescript requires the data, we’re mitigating the need for devs looking up what data is required as typescript will infer it for you. This way also saves you importing multiple data structures for each token type, keeping code cleaner and centralised. Having the token data coupled with the token type mitigates any developer mistakes, before compile time. 

Why

Creating typesafe code catches errors before compilation, saving time for devs and mitigating both developer errors and runtime bugs. It may be a bit of an investment in terms of time to set things up, but it’s definitely worth it in some scenarios. There is however, always the risk of going overboard and creating something too complicated and difficult to maintain. Choose wisely!

This website uses cookies

This website uses cookies to improve your experience. By using PRISM⁵⁵, you accept our use of cookies.