DEV Community

Cover image for Type flowing: Rethinking the TypeScript typing system
Matt Angelosanto for LogRocket

Posted on • Originally published at blog.logrocket.com

Type flowing: Rethinking the TypeScript typing system

Written by Yan Sun ✏️

Introduction

TypeScript provides a very rich toolbox. It includes mapped types, conditional types with control flow-based analysis, type inference, and many more.

It’s not an easy task for many JavaScript developers who are new to TypeScript to switch from loose typing to static typing. Even for developers who have been working in TypeScript for years, it can be confusing as the typing system continuously evolves.

A common myth about advanced types is that it should mainly be used for building type libraries and is not required for day-to-day TypeScript work.

The truth is that TypeScript advanced types are very useful for daily TypeScript work. They’re a great tool for building a strongly typed system into your code, expressing your intentions clearly, and making your code safer.

The purpose of introducing the type flowing concept is to think about the typing system in a way that’s similar to how we think about reactive programming data flow.

By looking at the typing system from a new perspective, it will help us to “think in types” and utilize the more advanced tools in the TypeScript toolbox in a systematic way.

Types can flow

In reactive programming, data flows between reactive components. In the TypeScript typing system, types can flow as well.

The first time I encountered the concept of “Type flowing” was in PranshuKhandal’s TypeScript book. He explains this idea in the following way:

The types flowing is just how I imagine in my brain the flow of type information.

Inspired by this, I wanted to expand the type flowing concept to the typing system level. My definition of type flowing is:

Type flowing is when one or more subtypes are mapped and transformed from a source type. These types form a strongly constrained typing system through type operations.

The basic form of type flowing can be done via type aliases.

Type aliases allow you to create a new type name for an existing type. In the example below, the type alias TargetType is assigned as a reference to the SourceType, so the type is transferred.

type SourceType = { id: string, quantity: number };
type TargetType= SourceType; // { id: string, quantity: number };
Enter fullscreen mode Exit fullscreen mode

Thanks to the power of type inferences, types can flow in a few different ways. These include:

  • Through data assignment
  • With the return type function, which is inferred by the return statements; e.g., the following function is inferred to return a number type
  • Pattern matching with function parameters: as illustrated in the below example, annotating the decrease function with “DecreaseType” type converts the a,b value to a number type

The following code snippets illustrate the above cases.

const quantity: number = 4;
const stockQuantity = quantity;
type StockType= typeof stockQuantity; // number

// function return type
function increase(a: number) {
    return a + 1;
}
const result = increase(2); // number

// function parameters matching
type DecreaseType = (start: number, offset: number) => number;
const decrease: DecreaseType = (a,b) => { // a: number, b: number
    return a- b;
}
Enter fullscreen mode Exit fullscreen mode

Reactive programming data flow vs. type flowing

The core of reactive programming is the flowing of data between the source and the reactive components. Some of its concepts are very similar to the TypeScript typing system, as you can see in the chart below.

Comparing RxJS Operators with TypeScript Types

You can see a comparison between the RxJS operator concepts and TypeScript typing. In the typing system, the type can be transformed, filtered, and mapped to one or more subtypes.

These subtypes are “reactive” as well. When the source type changes, the subtypes will be updated automatically.

A well-designed type system will add strongly typed constraints to the data and functions in the app, so any breaking changes made to the source type definition will show an immediate compile time error.

While there are some similarities between RxJS and TypeScript typing, there are still a lot of differences between the two. For example, the data flow in RxJS occurs at runtime, while type flowing in TypeScript occurs at compile time.

The purpose of referencing RxJS here is to illustrate the flow concept in RxJS, which will hopefully help us build a shared understanding of “thinking with types.”

Type operations in type flowing

Map and filter

The two most frequently used operators in reactive programming are map and filter. How should we perform these two operations for types in TypeScript?

The mapped type is the equivalent of the map operator in RxJS. It allows us to create a type that is based on another type using the first one’s index signature and generic types.

When you combine conditional types with type inference, the type transformations you can achieve with mapped types are beyond imagination. We’ll discuss how to use mapped types later in the article.

The equivalent of arrays in type flowing is the union type. To apply filters on union types, we need to use conditional types and the never type. As filter is such a common need, TypeScript provides the exclude and extract utility types right out of the box.

The below code uses conditional types to remove types from T that are not assignable to U. The never type is used here for type narrowing, or filtering out the options of a union type.

type Exclude<T, U> = T extends U ? never : T;
type T1 = Exclude<"a" | "b" | "c" , "a" >; // "b" | "c"
type Extract<T, U> = T extends U ? T: never;
type T2 = Extract<"a" | "b" | "c" , "a" >; // "a"
Enter fullscreen mode Exit fullscreen mode

We can also filter the type properties using the out-of-the-box TypeScript utility types.

type Omit<T, K extends string | number | symbol> = {[P in Exclude<keyof T, K>]: T[P]; }
type T1 = Omit<{ a: string, b: string }, "a"> //  { b: string; }
Enter fullscreen mode Exit fullscreen mode

Pipe the flow with control flow analysis

Using control flow analysis with type guard, we can pipe the flow the way the pipe operator does in RxJS.

Below is an example using type guard to perform type checks, which narrow the type to a more specific one and control the logic flow.

function doSomething(x: A | B) {
 if (x instanceof A) {
   // x is A
 } else {
   // x is B
 }
}
Enter fullscreen mode Exit fullscreen mode

In the above example, the TypeScript compiler analyzes all possible flows of control for the expression. It looks at the x instance of A to determine the type of x to be A within the if block, and narrows the type to B in the else block.

If we think of that as the logic branching, it can be used in a connective way, similar to the way water flows through a pipe and can be redirected to a different, connected pipe to reach its destination.

Building strong constraints using type flowing

With the theory out of the way, let’s put this idea into practice. Below, we’ll look at how the type flowing concept can be put into practice by mapping, filtering, and transforming the types to implement a well-constrained typing system.

Defining the mapper methods

We are building a Node.js app with the mapper pattern. To implement the pattern, we must first define some mapper methods, which take data entity objects and map them to a data transfer object (DTO).

On the flip side, there is another set of methods to convert the DTOs to corresponding entity objects.

export class myMapper {
   toClient(args: ClientEntity) : ClientDto { ...};
   fromClient(args: ClientDto) : ClientEntity{ ...};
   toOrder(args: OrderEntity) : OrderDto { ...};
   fromOrder(args: OrderDto) : OrderEntity{ ...};
}
Enter fullscreen mode Exit fullscreen mode

We’ll use a simplified, contrived example to demonstrate the following two goals with our strong typing system:

  1. Create a type for the mapper with all the methods and interfaces from the data schema
  2. Create a union type to represent entity names for type safety

Defining the data entity and DTO types

First, we need to define the types for the entities and DTOs.

type DataSchema = {
  client: {
      dto:  { id: string, name: string},
      entity: {clientId: string, clientName: string}
  },
  order:  {
      dto:  { id: string, amount: number},
      entity: {orderId: string, quantity: number}
  },
}
Enter fullscreen mode Exit fullscreen mode

Now that we have the raw data types defined, how do we extract each entity and DTO type from them?

We’ll use conditional types and never to filter out the required data type definitions.

type PropertyType<T, Path extends string> = Path extends keyof T ? T[Path] : never;

type lookup<T, Key extends keyof T, prop extends string> = PropertyType<T[Key], prop>;
Enter fullscreen mode Exit fullscreen mode

We can simplify the above by merging them into a single lookup type.

type lookup<T, Key extends keyof T, prop extends string> = prop
extends keyof T[Key] ? T[Key][prop] : never;
Enter fullscreen mode Exit fullscreen mode

Support for nested properties

The above lookup type only works for a single-level property. What happens when the source type has more depth?

To access a property type with more depth, we’ll create a new type with recursive type aliases.

type PropertyType<T, Path extends string> =
    Path extends keyof T ? T[Path] :
        Path extends `${infer K}.${infer R}` ? K extends keyof T ? PropertyType<T[K], R> : 
        never :
    never;

type lookup<T, Key, prop extends string> = Key extends keyof T? PropertyType<T[Key], prop>: never;
Enter fullscreen mode Exit fullscreen mode

When Path extends keyof T is truthy, it means the full path is matched. Thus, we return the current property type.

When Path extends keyof T is falsy, we use the infer keyword to build a pattern to match the Path. If it matches, we make a recursive call to the next-level property. Otherwise, it will return a never and that means the Path does not match with the type.

If it does not match, continue recursively with the current property as the first parameter.

Defining the mapper type and methods

Now, it’s time to create the mapper methods. Here, we use string literal types to form MapTo and MapFrom with the help of the Capitalize utility type.

// MapTo and MapFrom
type MapTo<T extends string> = `to${Capitalize<T>}`;
type MapFrom<T extends string> = `from${Capitalize<T>}`;
Enter fullscreen mode Exit fullscreen mode

Putting it all together

When we assemble the previous parts, our first goal is achieved!

We make use of the key remapping feature (i.e., the as clause in the below code block), which has only been available since the release of TypeScript 4.1.

Please also note that Key extends string ? Key : never is needed because the type of object keys can vary among strings, numbers, and symbols. We are only interested in the string cases here.

type ExtractMapperTo<T> = {
  [Key in keyof T as MapTo<Key extends string ? Key : never>]: (args: lookup<T, Key, 'dto'>) => lookup<T, Key, 'entity'>;
};

type ExtractMapperFrom<T> = {
  [Key in keyof T as MapFrom<Key extends string ? Key : never>]:(args: lookup<T, Key, 'entity'>) => lookup<T, Key, 'dto'>;
};

// Then all these mapper methods are automatically created 
type mapper = ExtractMapperTo<DataSchema> & ExtractMapperFrom<DataSchema>;
Enter fullscreen mode Exit fullscreen mode

We can see below that all of the mapper method interfaces are automatically created.

// Our first goal achieved!
declare const m: mapper;
m.toClient({id: '123', name: 'John'});
m.fromClient({clientId: '123', clientName: 'John'});
m.toOrder({id: '123', amount: 3});
m.fromOrder({orderId: '345',quantity: 4});
Enter fullscreen mode Exit fullscreen mode

We have nice IDE IntelliSense support as well.

IDE IntelliSense support

Converting an object type to a union type

Our next goal is to create a union type to represent data type names from the source DataSchema type.

The key to the solution is the PropToUnion<T> type.

// Derive the data type names into a union type
type PropToUnion<T> = {[k in keyof T]: k}[keyof T];

type DataTypes = PropToUnion<DataSchema>; // 'client' | 'order'
Enter fullscreen mode Exit fullscreen mode

First, {[k in keyof T]: k} extracts the key of T as both key and value using keyof. The output is:

{
   client: "client";
   order: "order";
}
Enter fullscreen mode Exit fullscreen mode

Then, we use the index signature [keyof T] to extract the values as a union type.

The generated union type can help us enforce type safety. Let's say we have placed the following function in another module far away from the source type. In the getProcessName function, the switch statement triggers the type guard and never is returned in the default case to tell the compiler that it should never be reached.

// Second goal achieved
function getProcessName(c: DataTypes): string {
    switch(c) {
        case 'client':
            return 'register' + c;
        case 'order':
            return 'process' + c;
        default:
        return assertUnreachable(c);
    }   
}
function assertUnreachable(x: never): never {
    throw new Error("something is very wrong");
}
Enter fullscreen mode Exit fullscreen mode

This is how the union type and never help to enforce type safety.

Now, let’s assume there’s been a change to the data schema — we add a new data type called account. In a large team, the developer adding the new type may not be aware of the impact of the change. Without typing constraints, it could result in a hidden runtime error that is hard to find.

If we use type flowing to build the typing constraints, the downstream subtypes will be automatically updated as below.

type DataTypes = "client" | "order" | "Account"
Enter fullscreen mode Exit fullscreen mode

The TypeScript compiler will also show an error in the getProcessName function to prompt us that a breaking change has occurred.

A compiler error due to a breaking change

Our second goal is achieved! We have a union type that now represents entity names and contributes to type safety.

To recap, this diagram shows the main steps we took to achieve the first goal of type flowing.

A visual recap demonstrating the flow of types

Overall, we created several new types based on the original source type. Any changes to the source type will trigger updates to all of the downstream types automatically, and we will get an instant error prompt if the change breaks the functions that depend on it.

The full example code can be found in the Gist.

Summary

This article discusses the TypeScript type flowing concept with reference to reactive programming in RxJS. We applied the type flowing concept to a practical example by building a well-constrained type system to maximize the benefits of type safety.

I hope this discussion helps to change the idea that TypeScript’s advanced types are only for developing type libraries or for complex framework-level programming. I also hope it can help you to start applying the typing system more creatively in your daily TypeScript work.

Happy typing!


LogRocket: Full visibility into your web apps

LogRocket Dashboard Free Trial Banner

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.

Try it for free.

Discussion (0)