fontcolor_theme

Runtime Types

10 Aug 25, Marc

TypeScript types vanish at runtime. This is by design.

Yet you need type information at runtime for validation, serialization, forms, and dependency injection. Without access to types, you're forced to define schemas separately using libraries like Zod, class-validator, or JSON Schema.

This is a simple TypeScript interface:

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

Clean, readable, familiar. Every TypeScript developer knows this syntax. But since it vanishes at runtime, you need to redefine it for runtime use.

Schema libraries (Zod, Yup, etc.) introduce their own DSL:

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>;

Decorator-based libraries (class-validator, TypeORM) duplicate type info in decorators:

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

  @IsDate()
  registered!: Date;

  @IsString()
  username!: string;

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

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

JSON Schema requires a completely separate format:

{
  "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"]
}

Each approach has its own syntax, its own limitations, and its own ecosystem. They're incompatible with each other. None can express the full power of TypeScript's type system. And switching between them means rewriting every schema.

Runtime Types with Deepkit

Deepkit's type compiler makes TypeScript types available at runtime. The same interface, no changes:

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

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

// Deserialize and validate JSON input
const user = cast<User>({
  id: 1,
  registered: '2023-05-12T14:43:30.690Z',
  username: 'Peter'
});
user.registered instanceof Date // true

// Serialize to JSON-safe output
serialize<User>(user);
// { id: 1, registered: '2023-05-12...', username: 'Peter' }

// Validate without casting
validate<User>({ id: 'not a number' });
// [{ path: 'id', message: 'Not a number' }]

// Full type reflection
typeOf<User>();
// { kind: 'class', properties: [...], ... }

One interface. Four operations. No schema duplication.

Validation with Type Annotations

TypeScript types describe shape, not constraints. You can say id: number, but not "id must be positive."

Deepkit solves this with type annotations: types that attach metadata to other types.

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

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

The & operator attaches constraints. Positive ensures the number is greater than zero. MinLength<3> requires at least 3 characters. These compose naturally:

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

type ID = number & Positive;
type Username = string & Unique & 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' });  // valid
validate<User>({ id: -1, registered: new Date(), username: 'Peter' }); // invalid: id not positive

You can also define custom validators:

function usernameValidator(value: string) {
  if (value.trim().length < 5) {
    return new ValidatorError('tooShort', 'Username is too short');
  }
}

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

End-to-End Type Usage

With runtime types, a single type definition works everywhere.

import { AutoIncrement, MinLength, 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) {}
}

This class now works with the database, HTTP layer, frontend, and dependency injection. No additional schema definitions needed.

Database

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

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

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

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

The ORM reads PrimaryKey, AutoIncrement, and Unique directly from the type annotations.

HTTP Server

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

class UserController {
  constructor(private database: Database) {}

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

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

HttpBody<User> tells the router to parse, validate, and deserialize the request body as a User. When create()executes, validation has already passed.

Frontend

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

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

// Receive
const response = await fetch('/user/123');
const user = cast<User>(await response.json());
// user.registered is a Date, not a string

serialize() converts Dates to ISO strings, typed arrays to base64, etc. cast() reverses this and validates the data.

Derived Types

Need a variant without certain fields? Use TypeScript's utility types:

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

All TypeScript type expressions work: Omit, Pick, Partial, intersection types, and more.

// Add a password field for registration
type RegisterUser = Omit<User, 'id' | 'registered'> & {
  password: string & MinLength<8>
};

@http.POST('/register')
async register(data: HttpBody<RegisterUser>) { /* ... */ }

Typed Route Parameters

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

class UserController {
  @http.GET('/user/:id')
  async get(id: number & Positive): Promise<User> {
    // id is guaranteed to be a positive number
  }

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

  @http.GET('/users/filter')
  async filter(query: HttpQueries<{ username?: string; role?: Role }>): Promise<User[]> {
    // /users/filter?username=Peter&role=admin
  }
}

This also works with function-based routes:

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

Dependency Injection

Notice how database: Database appears in the examples above? Because types exist at runtime, the framework knows exactly what to inject. No decorators required.

Compare with the traditional approach:

// Traditional: decorators everywhere
import { Inject, Injectable, Optional } from '@angular/core';

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

With runtime types:

// Runtime types: just TypeScript
class MyService {
  constructor(
    private http: HttpClient,
    private logger?: Logger
  ) {}
}

As you see, there is no code whatsoever imported for the DI framework. It operates on pure TypeScript.

This also enables proper dependency inversion: depending on interfaces rather than implementations.

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

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

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

No decorators, no tight coupling to concrete implementations.

RPC: Type-Safe Service Communication

For microservices or any service-to-service communication, Deepkit provides a binary RPC protocol with automatic serialization based on your types.

Define your service contract as a TypeScript class:

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

@rpc.controller('user')
class UserController {
  constructor(private database: Database) {}

  @rpc.action()
  async create(user: Omit<User, 'id' | 'registered'>): Promise<User> {
    const newUser = cast<User>({ ...user, registered: new Date() });
    await this.database.persist(newUser);
    return newUser;
  }

  @rpc.action()
  async get(id: number & Positive): Promise<User> {
    return await this.database.query(User).filter({ id }).findOne();
  }

  @rpc.action()
  async list(): Promise<User[]> {
    return await this.database.query(User).find();
  }
}

On the client side, call these methods with full type safety:

import { RpcClient } from '@deepkit/rpc';
import { RpcTcpClientAdapter } from '@deepkit/rpc-tcp';

const client = new RpcClient(new RpcTcpClientAdapter('localhost:8081'));
const userService = client.controller<UserController>('user');

// Fully typed - IDE autocomplete, compile-time checks
const user = await userService.create({ username: 'peter', firstName: 'Peter' });
console.log(user.registered); // Date object, not string

const users = await userService.list();

The RPC layer handles:

  • Binary serialization (more efficient than JSON)

  • Automatic type conversion (dates, typed arrays, class instances)

  • Validation on both ends

  • WebSocket and TCP transports

No code generation, no .proto files, no separate schema definitions. The TypeScript types are the contract.

Conclusion

One type definition. Used everywhere: frontend forms, HTTP requests, API validation, database operations, dependency injection.

No schema duplication. No decorator boilerplate. No incompatible libraries. Just TypeScript.

This is what runtime types enable: true end-to-end type safety from a single source of truth.

Learn More