DEV Community

A. Sharif
A. Sharif

Posted on

Notes on TypeScript: Type Level Programming Part 3

Introduction

These notes should help in better understanding TypeScript and might be helpful when needing to lookup up how to leverage TypeScript in a specific situation. All examples in this post are based on TypeScript 3.7.2.


TL;DR:

Liquid error: internal

Type Level Programming

This is the third part, where we focus on type level programming in TypeScript. For a better understanding of this post, it might be helpful to have a basic understanding of mapped and lookup types as well as conditional types. Additionally reading the first two posts about type level programming can be helpful too.
You can find the following posts here:


Examples

In this part, we're going to write some helper functions that enables us to do type transformations based on a specified type. There are cases where we might want to make all properties of a specific type readonly or optional or required. There also might be siutations where we need to transform all properties of a defined type to another type.

Let's assume we have User type.

type User = {
  name: string;
  points: number;
  active: boolean;
};
Enter fullscreen mode Exit fullscreen mode

How can convert anything of type boolean to true for example, as there might be a specific part of our application, where a User has to be in an active state? There might be situations where we need to transform all properties of a specific type to a different type. One example would be converting a boolean flag to a string after validating some object and wanting to display error messages.

type User = {
  points: number;
  name: string;
  active: boolean;
};

/**
  type ActiveUser = {
    points: number;
    name: string;
    active: true;
  }
 */
type ActiveUser = ConvertTo<User, boolean, true>;
Enter fullscreen mode Exit fullscreen mode

Our ConvertTo helper should be able to receive some type T and the type we want to convert from as well as the type we want to convert to.

type ConvertTo<Type, From, To> = {
  [Key in keyof Type]: Required<Type>[Key] extends From ? To : Type[Key];
};
Enter fullscreen mode Exit fullscreen mode

The ConvertTo helper maps over the provided type and then checks if a property extends the type we want to transform, either keeping the original type or applying the type we want to transform to. One important note here, is that we need to make the lookup strict via Required<Type>[Key], otherwise any optional properties will be skipped in the mapping function.

Next, we will write some helper functions, which we can use a basic building blocks for writing more specific helpers later on. One basic functionality is to be able to filter based on a defined type. The general use case might not be interesting from a user land perspective, as there might not be too many situations where want to remove all properties that are not of a specific type. The FilterByType function only returns keys that match the type we want to filter by.

type FilterByType<T, U> = {
  [Key in keyof Required<T>]: Required<T>[Key] extends U ? Key : never;
}[keyof T];
Enter fullscreen mode Exit fullscreen mode

Using the FilterByType:

type User = {
  id: number;
  name: string;
  points: number;
  active: boolean;
};

type FilteredUserKeys = FilterByType<User, number>;
// type FilteredUserKeys = "id" | "points"
Enter fullscreen mode Exit fullscreen mode

From the above example we can see that this helper functions filters all the keys that have the type number for the provided User shape. If we recall, Omit and Pick both expect a type and the keys to be applied. By combing Omit or Pick with our previously defined FilterByType helper, we can create two new useful helpers: IncludeByType and ExcludeByType. The next example should help us understand how this works.

type ExcludeByType<T, U> = Omit<T, FilterByType<T, U>>;

type IncludeByType<T, U> = Pick<T, FilterByType<T, U>>;
Enter fullscreen mode Exit fullscreen mode

The two generic helpers ExcludeByType and IncludeByType expect a type T and the type we want to filter by.

type User = {
  id: number;
  name: string;
  points: number;
  active: boolean;
};

/**
  type UserWithoutTypeNumber = {
    name: string;
    active: boolean;
  }
 */
type UserWithoutTypeNumber = ExcludeByType<User, number>;

/**
  type UserWithOnlyTypeNumber = {
    id: number;
    points: number;
  }
 */
type UserWithOnlyTypeNumber = IncludeByType<User, number>;
Enter fullscreen mode Exit fullscreen mode

The above examples are equivalent to writing:

type UserWithoutTypeNumber = Omit<User, 'name' | 'string'>;

type UserWithOnlyTypeNumber = Pick<User, 'id' | 'number'>;
Enter fullscreen mode Exit fullscreen mode

So IncludeByType and ExcludeByType can be used as further building blocks to construct helpers that take care of transforming types to readonly, optional or required. Now that we have all the low level helper functions in place we can combine these helpers with the TypeScript provided helpers. To make a specified properties read-only by type, we can use Readonly and combine it with the type T we want to transform.

type ReadOnlyByType<T, U> = T & Readonly<IncludeByType<T, U>>;
Enter fullscreen mode Exit fullscreen mode

The above code could also be rewritten, to make what is happening more clear, like so:

type ReadOnlyByType<Type, FilterType> = Type & Readonly<IncludeByType<Type, FilterType>>;
Enter fullscreen mode Exit fullscreen mode

Using ReadOnlyByType would make a defined type readonly by filtering all properties that extend the filter-by type, making them readonly and then combining them with original structure.

type User = {
  id: number;
  name: string;
  points: number;
  active: boolean;
};

/**
  type UserNumberReadOnly = {
    readonly id: number;
    name: string;
    readonly points: number;
    active: boolean;
  };
 */
type UserNumberReadOnly = ReadOnlyByType<User, number>;
Enter fullscreen mode Exit fullscreen mode

This enables us to prevent id and points to be overridden in this case.

const user: UserNumberReadOnly = {
  id: 1,
  name: "test",
  points: 20,
  active: true
};

user.id = 3;
// Cannot assign to 'id' because it is a read-only property.
user.points = 30;
// Cannot assign to 'points' because it is a read-only property.
Enter fullscreen mode Exit fullscreen mode

Finally, we might need the capability to transform some type to optional or required for a given shape T. Again, by using the TypeScript provided Partial we can now provide two helper functions that can either set a specific type to optional and all others to required or vice versa. Using our previously defined ExcludeByType or IncludeByType, we can make a specified type U to optional or required.

type OptionalByType<T, U> = Partial<IncludeByType<T, U>> & ExcludeByType<T, U>;

type RequiredByType<T, U> = Partial<ExcludeByType<T, U>> & IncludeByType<T, U>;
Enter fullscreen mode Exit fullscreen mode

This enables us now to transform our User type:

/**
  type UserNumbersOptional = {
    id?: number;
    name: string;
    points?: number;
    active: boolean;
  };
 */
type UserNumbersOptional = OptionalByType<User, number>;

/**
  type UserOnlyNumbersRequired = {
    id: number;
    name?: string;
    points: number;
    active?: boolean;
  };
 */
type UserNumbersRequired = RequiredByType<User, number>;

Enter fullscreen mode Exit fullscreen mode

There might be one last case, where we would like to only convert all properties of defined type to required, but not change the optional/required for any other properties. In this case we can use the TypeScript helper Required.

type OnlyMakeTypeRequired<T, U> = ExcludeByType<T, U> &
  Required<IncludeByType<T, U>>;
Enter fullscreen mode Exit fullscreen mode

OnlyMakeTypeRequired enables us to only make these properties required that fit a defined type U. Our User shape might contain optional and required fields, but only the types that match U will be transformed to required, leaving all other properties as is. Check the following example for better understanding.

type User = {
  id: number;
  points?: number;
  name: string;
  active?: boolean;
};

/**
  type User = {
    id: number;
    name: string;
    points: number;
    active?: boolean;
  }
 */
type RequiredByTypeOnlyUser = OnlyMakeTypeRequired<User, number>;

const user: RequiredByTypeOnlyUser = {
  id: 1,
  name: "Test"
};
// Error! points is required now!
// Property 'points' is missing in type '{ id: number; name: string; }' but required in type 'Required<Pick<User, "id" | "points">>'
Enter fullscreen mode Exit fullscreen mode

As we have seen in the previous examples, we can build helper functions when needed for specific situations by leverage type level programming. If you're interested to learn more about type level programming, you can study the TypeScript provided helpers like Partial, Pick or Readonly, furthermore the first two posts on type level programming (see links below) in the "Notes on TypeScript" series might be helpful as well.


Links


If you have any questions or feedback please leave a comment here or connect via Twitter: A. Sharif

Top comments (0)