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.
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.
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}
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.
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.
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.
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.
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.
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?
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.
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.
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.