Published on
-6 min read

Intermediate Typescript: Generics and Mapped Types

Table of Contents

In the last post, I covered Literal and Union types. Those types are great and can get you a long way when writing your apps. When your codebase starts to grow you may find your middleware/helper functions still have too general of types which leads to more type casting than you would like. This is where generics and mapped types come in.

Generics

If your first interaction with generics was with java in school they may have put a bad taste in your mouth. Generics can be very simple and can help a lot with not repeating yourself. The simplest way to think of them is sort of like a function that takes types as parameters and yields back a new type. To drive that point home lets look at a few simple examples.

type type AddString<T> = string | TAddString<function (type parameter) T in type AddString<T>T> = function (type parameter) T in type AddString<T>T | string;

type type NumOrString = string | numberNumOrString = type AddString<T> = string | TAddString<number>; // yields number | string
type type StringOrString = stringStringOrString = type AddString<T> = string | TAddString<string>; // yields just string

interface interface Box<T>Box<function (type parameter) T in Box<T>T> {
  Box<T>.value: Tvalue: function (type parameter) T in Box<T>T;
}

const const stringBox: Box<string>stringBox: interface Box<T>Box<string> = { Box<string>.value: stringvalue: 'aString' };
const const arrayNumBox: Box<number[]>arrayNumBox: interface Box<T>Box<number[]> = { Box<number[]>.value: number[]value: [1, 2, 3] };
const const literalBox: Box<"aLiteral">literalBox: interface Box<T>Box<'aLiteral'> = { Box<"aLiteral">.value: "aLiteral"value: 'aLiteral' };

Generics act as a template, you define a type using a type parameter (in these cases T, but it can be any identifier), when the generic is used it would fill in all instances of T with the passed in type.

The most likely instance you would run into generics is with functions. Generics in functions allows types to flow through it when the function does not really care about any specific type.

type type Nullable<T> = T | nullNullable<function (type parameter) T in type Nullable<T>T> = function (type parameter) T in type Nullable<T>T | null;
function function getWithDefault<T>(possibleValue: Nullable<T>, defaultVal: T): TgetWithDefault<function (type parameter) T in getWithDefault<T>(possibleValue: Nullable<T>, defaultVal: T): TT>(possibleValue: Nullable<T>possibleValue: type Nullable<T> = T | nullNullable<function (type parameter) T in getWithDefault<T>(possibleValue: Nullable<T>, defaultVal: T): TT>, defaultVal: TdefaultVal: function (type parameter) T in getWithDefault<T>(possibleValue: Nullable<T>, defaultVal: T): TT): function (type parameter) T in getWithDefault<T>(possibleValue: Nullable<T>, defaultVal: T): TT {
  if (possibleValue: Nullable<T>possibleValue) {
    // possibleValue is now T
    return possibleValue: NonNullable<T>possibleValue;
  }
  // returning a T when possibleValue is null
  return defaultVal: TdefaultVal;
}

const const nullableNum: Nullable<number>nullableNum: type Nullable<T> = T | nullNullable<number> = 10;
const const num: numbernum = function getWithDefault<number>(possibleValue: Nullable<number>, defaultVal: number): numbergetWithDefault(const nullableNum: numbernullableNum, 2); // num is now just a number type

In this example, we use the same type T 3 times, as a Nullable<T>, a default value, and the functions return type. Notice we do not need to say getWithDefault<number>(nullableNum,2), typescript infers that T should be set to number based on usage.

Generics to make types "flow"

Generics can help address a number of potential issues of types not "flowing" the way you want. A great example of this is a function modifying members of a union type. Recall the ApiEvent type from the last post

interface LoginEvent {
  LoginEvent.type: "login"type: 'login';
  LoginEvent.user: stringuser: string;
  LoginEvent.wasSuccessful: booleanwasSuccessful: boolean;
}

interface PostCreatedEvent {
  PostCreatedEvent.type: "postCreated"type: 'postCreated';
  PostCreatedEvent.name: stringname: string;
  PostCreatedEvent.body: stringbody: string;
  PostCreatedEvent.createdAt: DatecreatedAt: Date;
}

type type ApiEvent = LoginEvent | PostCreatedEventApiEvent = LoginEvent | PostCreatedEvent;

Now let's say you are making a function that will take an event, and add a new field logged: boolean to show the event was logged out. Your first attempt might look something like this.

function function addLogged(event: ApiEvent): ApiEvent & {
    logged: boolean;
}addLogged(event: ApiEventevent: type ApiEvent = LoginEvent | PostCreatedEventApiEvent): type ApiEvent = LoginEvent | PostCreatedEventApiEvent & { logged: booleanlogged: boolean } {
  return { ...event: ApiEventevent, logged: booleanlogged: true };
}

This makes sense initially however, when you go to use this function you notice an issue.

const const loginEvent: LoginEventloginEvent: LoginEvent = { LoginEvent.type: "login"type: 'login', LoginEvent.user: stringuser: 'john', LoginEvent.wasSuccessful: booleanwasSuccessful: true };

const const updated: ApiEvent & {
    logged: boolean;
}updated = function addLogged(event: ApiEvent): ApiEvent & {
    logged: boolean;
}addLogged(const loginEvent: LoginEventloginEvent);
var console: Consoleconsole.Console.log(...data: any[]): void
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static)
log
(const updated: ApiEvent & { logged: boolean; }updated.user);
Property 'user' does not exist on type 'ApiEvent & { logged: boolean; }'. Property 'user' does not exist on type 'PostCreatedEvent & { logged: boolean; }'.

Weird, it's obvious to you that all this function does is add on a field, why is typescript complaining that user does not exist on PostCreatedEvent? The issue is the function definition. Based on the types we pass in ApiEvent and get back ApiEvent with some extra stuff. To the type system we could just always return a PostCreatedEvent with the logged field.

Generics help us tell typescript what we put in, is what we are going get out. Let's re-write this function like so.

function function addLogged<T extends ApiEvent>(event: T): T & {
    logged: boolean;
}addLogged<function (type parameter) T in addLogged<T extends ApiEvent>(event: T): T & {
    logged: boolean;
}T extends type ApiEvent = LoginEvent | PostCreatedEventApiEvent>(event: T extends ApiEventevent: function (type parameter) T in addLogged<T extends ApiEvent>(event: T): T & {
    logged: boolean;
}T): function (type parameter) T in addLogged<T extends ApiEvent>(event: T): T & {
    logged: boolean;
}T & { logged: booleanlogged: boolean } {
  return { ...event: T extends ApiEventevent, logged: booleanlogged: true };
}

const const loginEvent: LoginEventloginEvent: LoginEvent = { LoginEvent.type: "login"type: 'login', LoginEvent.user: stringuser: 'john', LoginEvent.wasSuccessful: booleanwasSuccessful: true };
const const updated: LoginEvent & {
    logged: boolean;
}updated = function addLogged<LoginEvent>(event: LoginEvent): LoginEvent & {
    logged: boolean;
}addLogged(const loginEvent: LoginEventloginEvent);
var console: Consoleconsole.Console.log(...data: any[]): void
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static)
log
(const updated: LoginEvent & { logged: boolean; }updated.LoginEvent.user: stringuser); // no error

Notice we did not change any logic of the function, just the type definition. We can still only pass in ApiEvents but when we pass in a specific LoginEvent the type system knows we are only going to get back a LoginEvent. The extends keyword for generics is very powerful to restrict the possible allowed values for a function while still reasoning about specific types. This does not lose generality if you had a list of ApiEvents you could still map over them with this function.

Generics in type definitions

Sometimes you may have a few wrapper types that hold the same types. This example is a little contrived, but I've run into this a few times before.

type type ValidValue = string | numberValidValue = string | number;

interface WrapperOne {
  WrapperOne.type: "wrapperOne"type: 'wrapperOne';
  WrapperOne.value: ValidValuevalue: type ValidValue = string | numberValidValue;
  WrapperOne.info: string[]info: string[];
}

interface WrapperTwo {
  WrapperTwo.type: "wrapperTwo"type: 'wrapperTwo';
  WrapperTwo.value: ValidValuevalue: type ValidValue = string | numberValidValue;
  WrapperTwo.extra: numberextra: number;
}

type type Wrapper = WrapperOne | WrapperTwoWrapper = WrapperOne | WrapperTwo;

In this example lets say the wrappers should both hold the same value type (both string or both number). But with this definition you could do

const const wrappedValues: Wrapper[]wrappedValues: type Wrapper = WrapperOne | WrapperTwoWrapper[] = [
  {
    WrapperOne.type: "wrapperOne"type: 'wrapperOne',
    WrapperOne.value: ValidValuevalue: 'aVal', // this wrapper is using a string
    WrapperOne.info: string[]info: ['extra info', 'more info'],
  },
  {
    WrapperTwo.type: "wrapperTwo"type: 'wrapperTwo',
    WrapperTwo.value: ValidValuevalue: 1, // this wrapper is using a number
    WrapperTwo.extra: numberextra: 10,
  },
];

Since the value field can be string | number there is nothing stopping a user of this type to mix and match the wrapped value types in the objects of the array.

Generics can be used to "lock" the value type in for all elements of the array.

type type ValidValue = string | numberValidValue = string | number;

interface interface WrapperOne<T extends ValidValue>WrapperOne<function (type parameter) T in WrapperOne<T extends ValidValue>T extends type ValidValue = string | numberValidValue> {
  WrapperOne<T extends ValidValue>.type: "wrapperOne"type: 'wrapperOne';
  WrapperOne<T extends ValidValue>.value: T extends ValidValuevalue: function (type parameter) T in WrapperOne<T extends ValidValue>T;
  WrapperOne<T extends ValidValue>.info: string[]info: string[];
}

interface interface WrapperTwo<T extends ValidValue>WrapperTwo<function (type parameter) T in WrapperTwo<T extends ValidValue>T extends type ValidValue = string | numberValidValue> {
  WrapperTwo<T extends ValidValue>.type: "wrapperTwo"type: 'wrapperTwo';
  WrapperTwo<T extends ValidValue>.value: T extends ValidValuevalue: function (type parameter) T in WrapperTwo<T extends ValidValue>T;
  WrapperTwo<T extends ValidValue>.info: numberinfo: number;
}

type type Wrapper<T extends ValidValue> = WrapperOne<T> | WrapperTwo<T>Wrapper<function (type parameter) T in type Wrapper<T extends ValidValue>T extends type ValidValue = string | numberValidValue> = interface WrapperOne<T extends ValidValue>WrapperOne<function (type parameter) T in type Wrapper<T extends ValidValue>T> | interface WrapperTwo<T extends ValidValue>WrapperTwo<function (type parameter) T in type Wrapper<T extends ValidValue>T>;

Now when we say we have a Wrappers<string> both wrapper's value type will be string.

const const wrappedValues: Wrapper<string>[]wrappedValues: type Wrapper<T extends ValidValue> = WrapperOne<T> | WrapperTwo<T>Wrapper<string>[] = [
  {
    WrapperOne<string>.type: "wrapperOne"type: 'wrapperOne',
    WrapperOne<string>.value: stringvalue: 'aVal', // this wrapper is using a string
    WrapperOne<string>.info: string[]info: ['extra info', 'more info'],
  },
  {
    WrapperTwo<string>.type: "wrapperTwo"type: 'wrapperTwo',
    WrapperTwo<string>.value: stringvalue: 'I can only use string', // using a number here would now throw an error
    WrapperTwo<string>.info: numberinfo: 10,
  },
];

Mapped Types

Mapped Types are a specific kind of generic types to help you build out new types. You may have seen is the Record<K,V> type, this lets you define an object whose keys are in the type K and values are in the type V. You can define your own record type like so.

type type MyRecord<KeyType extends string, ValueType> = { [key in KeyType]: ValueType; }MyRecord<function (type parameter) KeyType in type MyRecord<KeyType extends string, ValueType>KeyType extends string, function (type parameter) ValueType in type MyRecord<KeyType extends string, ValueType>ValueType> = {
  [function (type parameter) keykey in function (type parameter) KeyType in type MyRecord<KeyType extends string, ValueType>KeyType]: function (type parameter) ValueType in type MyRecord<KeyType extends string, ValueType>ValueType;
};
const const myRecord: MyRecord<"foo" | "bar", string | number>myRecord: type MyRecord<KeyType extends string, ValueType> = { [key in KeyType]: ValueType; }MyRecord<'foo' | 'bar', number | string> = { foo: string | numberfoo: 10, bar: string | numberbar: 'string' };

All mapped types do is iterate over possible values to define new keys (notice the key in KeyType). Another common mapped type is Pick<T, Keys>, this will yield a new type by picking the set of properties (Keys) from T. You can define it like so.

type type myPick<Type, Keys extends keyof Type> = { [key in Keys]: Type[key]; }myPick<function (type parameter) Type in type myPick<Type, Keys extends keyof Type>Type, function (type parameter) Keys in type myPick<Type, Keys extends keyof Type>Keys extends keyof function (type parameter) Type in type myPick<Type, Keys extends keyof Type>Type> = {
  [function (type parameter) keykey in function (type parameter) Keys in type myPick<Type, Keys extends keyof Type>Keys]: function (type parameter) Type in type myPick<Type, Keys extends keyof Type>Type[function (type parameter) keykey];
};

// same record from the above example
type type OnlyFoo = {
    foo: string | number;
}OnlyFoo = type myPick<Type, Keys extends keyof Type> = { [key in Keys]: Type[key]; }myPick<typeof const myRecord: MyRecord<"foo" | "bar", string | number>myRecord, 'foo'>; // resulting type is {foo: number | string}

Typescript has a number of built-in Utility types, look over them all, they are extremely handy to avoid repeating yourself.

Incrementally Type an object

Mapped types are excellent for incrementally adding types when converting from javascript to typescript. Let's say you are adding types for database tables. When you first converted you may have made a type like type Tables = Record<string, any>. This doesn't but you much other than saying Tables is an object.

When you decide it's time to add types for tables you start by typing a simple database table like so

interface UserTable {
  UserTable.id: numberid: number;
  UserTable.username: stringusername: string;
  UserTable.email: stringemail: string;
}

Now you run into an issue, how can you add just this type to the Tables type we had before without specifying all your tables? Mapped types can set a default type for anything you have not explicitly set.

type type Tables = {
    [key: string]: any;
    users: UserTable;
}Tables = {
  users: UserTableusers: UserTable;
  [key: stringkey: string]: any;
};

type type Users = UserTableUsers = type Tables = {
    [key: string]: any;
    users: UserTable;
}Tables['users']; // This is the UserTable type above
type type Other = anyOther = type Tables = {
    [key: string]: any;
    users: UserTable;
}Tables['somethingElse']; // This is any

Now you can add types for just tables you have manually typed while allowing old code to still reference any untyped table.

Using Mapped Types instead of Enums

Typescript added Enums to Javascript, personally I would avoid them. There is some debate on how useful enums are, I will avoid that discussion here and show how what I do instead.

I like to use an as const object to hold my enum like types.

const const UserStates: {
    readonly guest: "guest";
    readonly loggedIn: "loggedIn";
    readonly paid: "paid";
}UserStates = {
  guest: "guest"guest: 'guest',
  loggedIn: "loggedIn"loggedIn: 'loggedIn',
  paid: "paid"paid: 'paid',
} as type const = {
    readonly guest: "guest";
    readonly loggedIn: "loggedIn";
    readonly paid: "paid";
}const;

type type UserStatesMap = {
    readonly guest: "guest";
    readonly loggedIn: "loggedIn";
    readonly paid: "paid";
}UserStatesMap = typeof const UserStates: {
    readonly guest: "guest";
    readonly loggedIn: "loggedIn";
    readonly paid: "paid";
}UserStates;
type type UserStates = "guest" | "loggedIn" | "paid"UserStates = type UserStatesMap = {
    readonly guest: "guest";
    readonly loggedIn: "loggedIn";
    readonly paid: "paid";
}UserStatesMap[keyof type UserStatesMap = {
    readonly guest: "guest";
    readonly loggedIn: "loggedIn";
    readonly paid: "paid";
}UserStatesMap]; // UserStates is now 'guest' | 'loggedIn' | 'paid'

function function handleState(state: UserStates): voidhandleState(state: UserStatesstate: type UserStates = "guest" | "loggedIn" | "paid"UserStates) {}

function handleState(state: UserStates): voidhandleState(const UserStates: {
    readonly guest: "guest";
    readonly loggedIn: "loggedIn";
    readonly paid: "paid";
}UserStates.guest: "guest"guest);

Now this looks like more code than just using a typescript enum, but this buys us a few things. Since the type is just an object map we can use mapped types to modify/filter our types as we need. Let's say we wanted to filter our UserStates type to not include guest accounts. We could do the following

type type NonGuest = "loggedIn" | "paid"NonGuest = {
  [function (type parameter) keykey in keyof type UserStatesMap = {
    readonly guest: "guest";
    readonly loggedIn: "loggedIn";
    readonly paid: "paid";
}UserStatesMap]: function (type parameter) keykey extends 'guest' ? never : type UserStatesMap = {
    readonly guest: "guest";
    readonly loggedIn: "loggedIn";
    readonly paid: "paid";
}UserStatesMap[function (type parameter) keykey];
}[keyof type UserStatesMap = {
    readonly guest: "guest";
    readonly loggedIn: "loggedIn";
    readonly paid: "paid";
}UserStatesMap]; // This type resolves to `'loggedIn' | 'paid'`

In this example we actually used something called a Conditional Type to filter our the guest type. These types basically provide the ternary operator to the typescript type system. In this example if the key extends 'guest' its value in the resulting object would be never. Then when we get all the values of the map the never type is dropped from the union.

Conditional and Mapped types show up together a lot. They can be very powerful to modify existing types. They allow you to avoid redefining types for small changes.

I plan on covering Conditional Types in more detail in my next post!

Wrap up

Generics and Mapped Types are extremely powerful tools to help types follow through your program and to avoid repeating yourself. They can be a little confusing to newcomers so be sure to comment them well.