Runtime TypeScript types change everything

TypeScript types matter.

If you are a developer working with TypeScript you probably are aware of the fact that TypeScript types are only available at compile time. Once the code is compiled to JavaScript the types are gone. You might think this is a good thing, right? After all, everyone tells you it's a good thing. I'll explain to you when it's not and how you can benefit dramatically from switching the paradigm.

TypeScript is predominantly used in the web development world. This means it's either used in frontend or backend development. Let's go through some use cases where you work with structured data at runtime.

  • Client: You send an HTTP request to a server. You have to serialize your data to JSON. This can often be done using JSON.stringify. But the more complex your data structure is, the less likely it is that JSON.stringify will work out of the box.
  • Server: You receive an HTTP request from a client. You have to deserialize the JSON data to a JavaScript object and validate it.
  • Database: You want to store your data in a database. You have to serialize your data to a format that the database understands. Once you fetch the data from the database you have to deserialize it to JavaScript objects. The ORM/Database library does it for you plus probably also has some validation logic.
  • RPC: You want to send data over a network. You have to serialize your data to a (binary) format that can be safely sent over the network. The receiver has to deserialize the data and validate it.
  • GUI: You want to display your data in a GUI. For example, you have something like a Role enum or union. You now want to display a select box so the user can choose a Role. You have to somehow get the enum or union values at runtime.
  • API: You want to build several CRUD HTTP routes based on entities. You have to somehow get the entity names and all property types at runtime.
  • Dependency Injection: You want to inject services automatically via a Dependency Injection Container. You have to somehow get the token at runtime and their interface types.
  • Documentation: You want to generate documentation from your code. You have to somehow get the entity names and all property types at runtime if you want to generate it dynamically.

Most people just use JSON.stringify and JSON.parse to serialize and deserialize their data. But this is a very limited approach since JSON itself is very limited. It doesn't support dates, binary data, classes, circular references, etc. For meta-data like GUI/API people usually just define some constants, effectivity duplicating the information that is already available in TypeScript types.

Now, there are meanwhile a lot of libraries that help you with this problem. But all of them have in common that you have to define a schema, a javascript object manually. They need to know at runtime in one way or another how your data looks like and what validation constraints it has.

Let's take a look at an example.

interface User {
    id: number;
    registered: Date;
    username: string;
    firstName?: string;
    lastName?: string;
}

This is probably how you start building your application. You define here and there some handy TypeScript types and use them where needed. But now you want to send and receive a User object over the network. You have to serialize it. The receiver has to deserialize and validate it.

Since this interface is lost at runtime, how would you make this information available in runtime? Well, let's take a look at one of the most popular libraries and how they solve this problem.

import { z } from 'zod';

const User = z.object({
    id: z.number(),
    registered: z.date(),
    username: z.string(),
    firstName: z.string().optional(),
    lastName: z.string().optional(),
});

type User = z.infer<typeof User>;

There are many libraries that look like that, varying in their API a bit, but the idea is always the same. You define a schema for your data via JavaScript calls.

The type information about User is now available at runtime. But what you have done here is you have defined a schema for your data using a completely new way that works completely different from how TypeScript types work, especially compared to our interface above.

Another approach you likely have seen already is using classes + decorators.

import { IsNumber, IsDate, IsString, IsOptional } from 'class-validator';

class User {
    @IsNumber()
    id!: number;

    @IsDate()
    registered!: Date;

    @IsString()
    username!: string;

    @IsOptional()
    @IsString()
    firstName?: string;

    @IsOptional()
    @IsString()
    lastName?: string;
}

Yet another approach is using JSON schema.

{
    "type": "object",
    "properties": {
        "id": {
            "type": "number"
        },
        "registered": {
            "type": "string",
            "format": "date-time"
        },
        "username": {
            "type": "string"
        },
        "firstName": {
            "type": "string"
        },
        "lastName": {
            "type": "string"
        }
    },
    "required": [
        "id",
        "registered",
        "username"
    ]
}

Do these look better or more intuitive than the TypeScript interface above? It's probably a matter of taste, but to me, the TypeScript interface is much more readable and intuitive. It also looks a lot more verbose compared to our simple interface above. And of course, TypeScript types are much more powerful than this API, so you can't even use all TypeScript type expressions.

While it's obvious to introduce a new API to define schemas for your data, it is not a good idea to introduce this type of complexity. There are meanwhile over a dozen of libraries that all have their own API to define schemas. And they all have in common that they are not compatible with each other. And it's unlikely that external libraries support your way of defining schemas. That makes it very hard for framework authors to pick one or design a system that works with all of them. And if you want to switch to another one, you have to rewrite all your schemas.

If you build a new TypeScript team, chances are high that everyone has learned their own exotic way of defining runtime types. But, do you know what they all have in common? They all know TypeScript.

So why not use TypeScript types to define schemas? Why not use the same syntax that you are already family with? Use just TypeScript.

Runtime types

Entering runtime types. Deepkit brings with its type plugin a new way of accessing TypeScript types at runtime, rendering all other approaches obsolete.

TypeScript types bring already everything to describe the shape and schema of our User type, so we can just stick with it and reuse it.

import { typeOf, cast } from '@deepkit/type';

interface User {
    id: number;
    registered: Date;
    username: string;
    firstName?: string;
    lastName?: string;
}

const type = typeOf<User>();

Wherever we need the runtime type information about User, we can simply access it with the function typeOf<T>(). The application developer as well as library authors can now use the same API to access the type information, based on simply regular TypeScript types.

Deepkit makes it easily possible to make these types available at runtime with a TypeScript plugin (like a babel plugin) or as Webpack-Loader, or Vite-Plugin.

Additionally, Deepkit comes with lots of useful utilities to work with runtime types, like cast<T>() to validate and convert (deserialize) data. This is a real typecast function you can use. It's not just a type assertion, but it actually validates the data and converts it to the correct type.

const user = cast<User>({ id: -1, registered: '2023-05-12T14:43:30.690Z', username: 'Peter' });
// user: {id: number, registered: Date, username: string}

Validation

But what about content validation, you might ask? With native TypeScript types we can only validate against the data types, not against the actual content.

But there is a solution to that. Introducing Decorator Types: A decorator type is like a normal TypeScript type, but it attaches additional information to the origin type. Deepkit's runtime type system makes it possible to attach arbitrary meta information to any type and comes with a set of decorator types out of the box, for validation, serialization, database, router, and more.

Let's demonstrate that with an example and add some validation rules.

import { MinLength, Positive } from '@deepkit/type';

interface User {
    id: number & Positive;
    registered: Date;
    username: string & MinLength<3>;
    firstName?: string;
    lastName?: string;
}

Here we use the & operator to attach additional information to the type. In this case, we use the Positive decorator type to tell that the id property is a positive number. We also use the MinLength decorator type to tell that the username property has to be at least 3 characters long. You can combine as many decorator types as you want, use type aliases, or even create your own.


import { MinLength, Positive, validate } from '@deepkit/type';

type ID = number & Positive;
type Username = string & MinLength<3> & MaxLength<20>;

interface User {
    id: ID;
    registered: Date;
    username: Username;
    firstName?: string;
    lastName?: string;
}

validate<User>({ id: 1, registered: new Date, username: 'Peter' }); //true
validate<User>({ id: -1, registered: new Date, username: 'Peter' }); //false

And just like that we have a fully typed User type with validation, you can use everywhere in your application.

Shared types

When building an isomorphic TypeScript application, you likely already share types between your services, like frontend, backend, microservice, etc. With runtime types, this can be brought to a whole new level.

Remember our User class? Imagine for a second you could use it everywhere, and I mean everywhere: Angular forms, fetch calls, HTTP routers, Database ORM. What would that look like?

import { AutoIncrement, MaxLength, PrimaryKey, Unique } from '@deepkit/type';

type Username = string & Unique & MinLength<3> & MaxLength<20>;

class User {
    id: number & PrimaryKey & AutoIncrement = 0;
    registered: Date = new Date();
    firstName?: string;
    lastName?: string;

    constructor(public username: Username) {
    }
}

Here we use the PrimaryKey decorator type to tell that the id property is the primary key of this entity. We also use the AutoIncrement decorator type to tell that the id property is automatically incremented by the database. We use the Unique decorator type to tell that the username property has to be unique. And we use the MaxLength decorator type to tell that the username property has to be at most 20 characters long.

There are many more decorator types and validation constraints available, and we could add many more validation rules and meta-data, like our own decorator types or use real function references for validation.

function usernameValidation(value: string, type: Type) {
    value = value.trim();
    if (value.length < 5) {
        return new ValidatorError('tooShort', 'Value is too short');
    }
}

type Username = string & Unique & Validate<typeof usernameValidation>;

But let's stick with our example for now. Now we can use this type everywhere in our application.

Database

Let's start with the database.

Using Deepkit ORM, you have already everything done at the User class to make it work with the database. You can use it as entity, create tables, query it, etc.

import { Database } from '@deepkit/orm';
import { SQLiteDatabaseAdapter } from '@deepkit/sqlite';

const database = new Database(new SQLiteDatabaseAdapter('path/to/database.sqlite'), [User]);

const user = new User('peter');
await database.persist(user);

const users = await database.query(User).find();

As you can see, we can use the User class as entity, and it works out of the box with the database.

HTTP

Next, let's accept a user via HTTP.

import { http } from '@deepkit/http';

class MyController {
    constructor(protected database: Database) {
    }

    @http.POST('/user')
    async addUser(user: HttpBody<User>) {
        await this.database.persist(user);
    }

    @http.GET('/user/:id')
    async getUser(id: number): Promise<User> {
        return await this.database.query(User).filter({ id }).findOne();
    }
}

See that HttpBody<User>? That's a decorator type that tells the HTTP router that the body of the request has to be a valid User. You can now send a User object via JSON to /user and the HTTP router will automatically validate the body and throw an error if it's not valid. At the point when addUser method is called, it's guaranteed that the body is a valid User with all validation rules applied.

Frontend

Next, let's use the User class in our front end.

import { cast, serialize } from '@deepkit/http';

//send a user as JSON
const newUser = new User('peter');
await fetch('/user', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(serialize<User>(newUser))
});

// fetch a user
const response = await fetch('/user/123');
const json = await response.json();
const user = cast(await response.json(), User);

Here we use the serialize function to serialize the User object to a JSON object. After JSON.stringify(), we can then pass it to the fetch call, which will send it to the server.

The serialize call is necessary to convert anything that needs conversion to a JSON object. For example, a Date object is not a valid JSON object, so it needs to be converted to a string first. A typed Array will be converted to a base64 string, etc.

On the other side, we use the cast function to convert the JSON object back to a User object. This will also validate the JSON object and throw an error if it's not valid. Note that you get a real instance of User, so all methods are available.

Derived types

Next, let's say you want to have derived variants of the User class, like a RegisterUser type, where registered is set by the server and shouldn't be set by the client. We simply use the power of TypeScript and derive a new type without that property, and then we can use this type in our HTTP controller.


import { http } from '@deepkit/http';

class MyController {
    @http.POST('/user')
    async addUser(user: HttpBody<Omit<User, 'registered'>>) {
        // ...
    }
}

Almost any TypeScript expression is possible. You can use Omit, Pick, Partial, Required, or create entirely new types with &, like HttpBody<User & {password: string & MinLength<4>}>.

The same works with HttpQuery (a single query parameter), HttpQueries (multiple query parameters), and HttpHeader.

Path parameters can be typed, too.

import { http, HttpQueries } from '@deepkit/http';
import { Positive } from '@deepkit/type';

class MyController {
    @http.GET('/user/:id')
    async getUser(id: number & Positive): Promise<User> {
        // ...
    }

    @http.GET('/users')
    async users(username: HttpQuery<string>): Promise<User> {
        // /users?username=Peter
    }

    @http.GET('/users/advanced')
    async usersComplex(filter: HttpQueries<{ username?: string, role?: Role }>): Promise<User> {
        // /users?username=Peter&role=Admin
    }
}

Here id is always a number, and always positive. That is what we've defined with the number & Positive. The router respects that, runs deserialization and validation, and throws an error if it's not valid.

Oh, and this also works with regular functions.

router.post('/user', async (
    body: HttpBody<Omit<User, 'registered'>>, database: Database
) => {
    const user = cast<User>({ ...body, registered: new Date() });
    await this.database.persist(user);
});

Compare that to other popular frameworks where you do not have access to TypeScript's powerful type system at runtime.

const User = z.object({
    id: z.number(),
    username: z.string(),
    firstName: z.string().optional(),
    lastName: z.string().optional(),
});

fastify.post < {Body: z.TypeOf < typeof User >} > ('/user', {
    schema: {
        body: zodToJsonSchema(User),
    },
    async: function postUser(req, res) {
        const body = req.body;
        // - get global databsae
        // - create a new User, assign registered = new Date()
        // - persist to database
    }
})

What would you prefer?

Dependency Injection

You probably already spotted something new in the last example. We defined a database argument that references a Database type. Since all types are available at runtime, the framework knows exactly how your router callback function looks like and can pass the Database instance automatically for you.

This is called dependency injection and is a very powerful concept. You can use it everywhere in your application. You can use it in your HTTP/RPC/CLI controllers, in your services, event listeners etc.

You might think that this is already possible with TypeScript, but it's not. All the Dependency Injection container out there use a workaround: Either you have to define your providers and dependencies as JavaScript (very much like User schema above with Zod) or you have to use TypeScript Decorators.

And especially the latter gets out of hand very quickly. Here's an example of a Angular service that uses dependency injection, some optional dependencies, and a custom token.

// bad example of DI with lots of boilerplate
import { Inject, Injectable, Optional } from '@angular/core';
import { Logger, LOGGER } from './logger';

@Injectable()
class MyService {
    constructor(
        private http: HttpClient,
        @Inject(LOGGER) @Optional() private logger?: Logger
    ) {
    }
}

Do you see all these decorators? What a mess. And it gets even worse when you have anything more complex than that. You have to use @Injectable and @Inject everywhere, and you have to use @Optional when you want to make a dependency optional. And you have to use @Inject when you want to use a custom token. The same is true for NestJS and other frameworks that rely on TypeScript decorators. Decorators are too limited, they do not work on functions, and they add endless boilerplate.

With runtime types, you can simply use TypeScript's type system. No decorator needed and it works perfectly with functions, too.

And for the first time ever, it's possible to apply very easily the Dependency Inversion principle. As a quick reminder, the Dependency Inversion principle states that you should depend on abstractions, not on concretions. In other words, you should depend on interfaces, not on implementations.

Let's say you have a Database class that implements a DatabaseInterface. You can now use the DatabaseInterface in your controllers, services, etc. and the framework will automatically inject the Database instance for you.

interface DatabaseInterface {
    persist(entity: any): Promise<void>;
}

class Database implements DatabaseInterface {
    async persist(entity: any): Promise<void> {
        // ...
    }
}

class MyController {
    constructor(private database: DatabaseInterface) {
    }

    @http.POST('/user')
    async addUser(body: HttpBody<Omit<User, 'registered'>>) {
        const user = cast<User>({ ...body, registered: new Date() });
        await this.database.persist(user);
    }
}

See, no decorators and MyController is not tightly coupled to the Database class implementation. This is incredible powerful especially in larger well decoupled applications.

Forms

Next, you can use the very same User class in your Angular application. You can use it in your forms, in your services, etc. You can even use it in your templates, because it's a real TypeScript class.

import { Component } from '@angular/core';
import { TypedFormGroup } from '@deepkit/type-angular';

@Component({
    selector: 'app-user',
    template: `
        <form [formGroup]="form">
            <input formControlName="username" />
            <input formControlName="firstName" />
            <input formControlName="lastName" />
        </form>
    `,
})
class AppUser {
    form = TypedFormGroup.fromEntityClass(User);

    async submit() {
        if (this.form.valid) {
            const user = this.form.value;
            await fetch('/user', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify(serialize<User>(user))
            });
        }
    }
}

And just like that, you have a fully typed form with validation. You can use the same User class everywhere in your application, and it will always be validated.

Conclusion

This makes the type usable end to end - from client frontend down to the database and back.

As you can see, we have defined a single User class and used it everywhere in our application. This can be done with any type. We created derived types where necessary, and used them in our HTTP client, our HTTP controller, in our database, in our Angular forms. Wherever you want.

And the best is it not only looks just like TypeScript, it is TypeScript. Its powerful type system is used now also in runtime. This is a huge productivity boost, only runtime types can give you. Use it to your advantage.

To learn more about the type system, check out Deepkit's runtime type documentation. In its documentation, you can read more about how it's done, what decorator types are available, and how to use your types efficiently with Deepkit ORM, Deepkit HTTP, Deepkit Dependency Injection, and much more.