loading...

Notes on TypeScript: Type Level Programming Part 2

busypeoples profile image A. Sharif Updated on ・5 min read

Introduction

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

Type Level Programming

In this part of the series we will continue with learning more about type level programming in TypeScript. It might be interesting to note, that to better understand this post, a basic understanding of conditional types as well as mapped and lookup types can be helpful.
You can find the following posts here:

Examples

In this part of the "Notes on TypeScript" series we will be writing some examples to solidify our existing knowledge on the topic.

Let's write an abstraction for defining an object type first.

export type ObjectType<Type> = { [k: string]: Type };

Now we can use this newly created to define an object, where we might not know all the properties, but know about the property types.

const o1: ObjectType<string> = {};
const o2: ObjectType<string> = { name: "Test ObjectType" };
//const o3: ObjectType<string> = {name: 1}; // Error! Type 'number' is not assignable to type 'string'.

We can take the same above approach and define an array type definition.

type ArrayType<Type> = { [k: number]: Type };

const a1: ArrayType<string> = [];
const a2: ArrayType<string> = ["Test 1", "Test2"];
// const a3 : ArrayType<string> = [1, 2]; // Error! Type 'number' is not assignable to type 'string'.

The above definition is only for demonstration purposes, as the built-in Array type already covers this case. But the example shows how we can create our own types and type helpers.

There might be a situation, where we might be interested in converting an existing object by changing the property type definitions.

type MakeRecord<Keys, Type> = { [Key in keyof Keys]: Type };

type User = {
  id: number;
  name: string;
};

type UserPropertiesToString = MakeRecord<User, string>;

/*
  type UserPropertiesToString = {
    id: string;
    name: string;
  }
*/

const r1: UserPropertiesToString = { id: "1", name: "Test" };
// const r2: UserPropertiesToString = { id: 1, name: "Test" }; // Error! Type 'number' is not assignable to type 'string'.

Let's start build some more advanced examples next. With type level programming we can extend TypeScript to build more convenience types.

We could write our own Omit type via leveraging the existing Pick type.

type Omit<Type, Keys> = Pick<Type, Exclude<keyof Type, Keys>>;

type UseWithOnlyId = Omit<User, "name">;

/*
  type UserWithOnlyId = {
    id: number;
  };
*/

The interesting aspect here, is that we can build more types on top of other types we created. For example we might want to be able to transform an existing type.

type MakeOverwrite<T, U> = Omit<T, keyof T & keyof U> & U;

Now we can use MakeOverwrite to override an existing type. Let's see how this works via an example.

type UserWithStringId = MakeOverwrite<
  User,
  {
    id: string;
  }
>;

const userWithStringId: UserWithStringId = {
  id: "1",
  name: "Test"
};

// const userWithStringId2 : UserWithStringId = {
//   id: 1,
//   name: "Test"
// };
// Error! Type 'number' is not assignable to type 'string'.

Or we might want to extend as well as override an existing type.

type UserWithPoints = MakeOverwrite<
  User,
  {
    id: string;
    points: number;
  }
>;

const userWithPoints: UserWithPoints = {
  id: "1",
  name: "Test",
  points: 0
};

// const userWithPoints: UserWithPoints = {
//   id: "1",
//   name: "Test",
// };
// Error! Property 'points' is missing.

We can see how we can keep extending the types. Let's see another interesting helper type we might want to implement when working with collections.

type GetHeadType<Type> = Type extends [infer H, ...any[]] ? H : never;

Now we might want to infer the type of the first item in an array. By using GetHeadType we can infer the type of that initial item.

type HeadType = GetHeadType<["Test", 1, { id: 2 }]>; // type HeadType = "Test"
type HeadType2 = GetHeadType<[1, { id: 2 }, "Test"]>; // type HeadType2 = 1
type HeadType3 = GetHeadType<[{ id: 2 }, 1, "Test"]>; // type HeadType3 = {id: 2;}

Using conditional types, we can write logical types.

type Not<Type extends boolean> = Type extends true ? false : true;

We might use Not to ensure that one type is the opposite of a provided type.

type TestNot = Not<true>; // type TestNot = false;
type TestNot2 = Not<false>; // type TestNot = true;

Let's build some more examples to solidify our knowledge.

There might be a situation where we might want to convert all type properties to strict.

type MakeStrict<Type> = { [Key in keyof Type]-?: Type[Key] };

type Product = {
  id: number;
  amount: number;
  name: string;
  description: string;
  locationId?: number;
};

type StrictProduct = MakeStrict<Product>;

/*
  type StrictProduct = {
    id: number;
    amount: number;
    name: string;
    description: string;
    locationId: number;
  };
*/

Via using -? we can tell TypeScript to remove the optional parameter.
The exact opposite of strict, making a type optional, can be achieved by using +?.

type MakeOptional<Type> = { [Key in keyof Type]+?: Type[Key] };

type OptionalProduct = MakeOptional<Product>;

/*
  type StrictProduct = {
    id?: number;
    amount?: number;
    name?: string;
    description?: string;
    locationId?: number;
  };
*/

If we need to remove all optional properties, we could write our own type that removes all optional properties.

type RequiredKeys<Type> = {
  [Key in keyof Type]: {} extends { [K in Key]: Type[Key] } ? never : Key
}[keyof Type];

type OnlyRequired<T> = Pick<T, RequiredKeys<T>>;

type OnlyRequiredProduct = OnlyRequired<Product>;

/*
  type OnlyRequiredProduct = {
    id: number;
    amount: number;
    name: string;
    description: string;
  };
*/

The interesting part here is that {} extends {id?: number} is true but that {} extends {id: number}} is false, which enables to identify if a property is optional or not.

type OptionalExtendsTest = {} extends {id?: number} ? true: false; // type OptionalExtendsTest = true
type OptionalExtendsTest2 = {} extends {id: number} ? true: false; // type OptionalExtendsTest = false

There might be a situation where we want to find out which properties are of a specific type.

type MakeKeysByType<Type extends {}, SearchType> = {
  [Key in keyof Type]-?: Type[Key] extends SearchType ? Key : never
}[keyof Type];

type ProductPropsByTypeNumber = MakeKeysByType<Product, number>;
// type ProductPropsByTypeNumber = "id" | "amount"

MakeKeysByType selects all keys of a specific type, but removes any optional properties.

We should have a good understanding of how to leverage type level programming in TypeScript now. In case you need a better understanding of the basics, the following posts might be helpful:

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

Posted on by:

busypeoples profile

A. Sharif

@busypeoples

Focusing on quality. Software Development. Product Management. https://twitter.com/sharifsbeat

Discussion

markdown guide