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.
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.
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>;
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.
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.
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.
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.
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>) { /* ... */ }
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); });
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.
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.
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.