Skip to content

Schema-First Types

TypoKit’s schema-first approach starts with a single principle: define your data types once, and let the build pipeline derive everything else.

One plain TypeScript interface becomes the single source of truth for:

  • Runtime validators — request/response validation with zero dependencies
  • Database schema — DDL, migrations, and ORM models
  • OpenAPI 3.1 spec — automatically generated API documentation
  • Type-safe client — fetch wrapper with full autocomplete
  • Test factories — valid/invalid data generators for contract tests
  • Diff reports — migration drafts when types change

No code duplication. No sync issues. Change the type, and everything downstream updates on the next build.

Entity types are plain TypeScript interfaces annotated with lightweight JSDoc tags. Here’s a complete User entity:

src/entities/user.ts
/** @table users */
export interface User {
/** @id @generated uuid */
id: string;
/** @format email @unique */
email: string;
/** @minLength 2 @maxLength 100 */
displayName: string;
/** @default "active" */
status: "active" | "suspended" | "deleted";
bio?: string;
/** @generated now */
createdAt: Date;
/** @generated now @onUpdate now */
updatedAt: Date;
}

That’s it. No decorators, no schema DSL, no runtime library — just TypeScript with JSDoc annotations that the TypoKit build pipeline reads at compile time.

TypoKit recognizes the following JSDoc tags on interfaces and their fields:

TagPurposeExample
@tableMaps the interface to a database table name@table users
TagPurposeExample
@idMarks field as the primary key@id
@generatedAuto-generated value — skipped in create inputs@generated uuid, @generated now
@onUpdateValue regenerated on every update@onUpdate now
@uniqueAdds a unique constraint@unique
@indexAdds a database index@index
@formatString format validation@format email, @format url, @format uuid, @format date-time
@minLengthMinimum string length@minLength 2
@maxLengthMaximum string length@maxLength 100
@defaultDefault value for the field@default "active", @default now()

Here’s what happens when the build pipeline processes the User type:

FieldTag(s)ValidatorDatabase Column
id@id @generated uuidSkipped in CreateUserInputuuid DEFAULT gen_random_uuid() PRIMARY KEY
email@format email @uniqueEmail format checkvarchar(255) NOT NULL UNIQUE
displayName@minLength 2 @maxLength 100Length bounds checkvarchar(100) NOT NULL
status@default "active"Union type checkuser_status ENUM DEFAULT 'active' NOT NULL
bio(optional field)Optional stringtext NULL
createdAt@generated nowSkipped in CreateUserInputtimestamp DEFAULT now() NOT NULL
updatedAt@generated now @onUpdate nowSkipped in CreateUserInputtimestamp DEFAULT now() NOT NULL

You rarely send the full entity type over the wire. TypoKit uses standard TypeScript utility types to create input/output contracts from your base entity:

/** Fields the client sends when creating a user */
export type CreateUserInput = Omit<User, "id" | "createdAt" | "updatedAt">;
// Resulting shape:
// {
// email: string; — required, validated as email
// displayName: string; — required, 2-100 chars
// status?: "active" | "suspended" | "deleted"; — optional, has @default
// bio?: string; — optional
// }
/** Fields the client can send in a PATCH request */
export type UpdateUserInput = Partial<CreateUserInput>;
// Resulting shape:
// {
// email?: string;
// displayName?: string;
// status?: "active" | "suspended" | "deleted";
// bio?: string;
// }
/** What the API returns to clients */
export type PublicUser = Omit<User, "status">;
// Use this to control what's exposed in API responses
/** Admin-only response with all fields */
export type AdminUser = User;
/** List response with pagination */
export type UserListResponse = PaginatedResponse<PublicUser>;
/** Summary for dropdown/autocomplete UIs */
export type UserSummary = Pick<User, "id" | "displayName" | "email">;

These derived types flow into route contracts, where they become the input and output schemas for each API endpoint:

export interface UsersRoutes {
"POST /users": RouteContract<void, void, CreateUserInput, PublicUser>;
"PATCH /users/:id": RouteContract<{ id: string }, void, UpdateUserInput, PublicUser>;
"GET /users/:id": RouteContract<{ id: string }, void, void, PublicUser>;
"GET /users": RouteContract<void, { page?: number; pageSize?: number }, void, UserListResponse>;
}

When you run typokit build, the native Rust pipeline parses your type files, extracts JSDoc metadata, and emits several artifacts into the .typokit/ directory:

Generated using Typia — extremely fast, zero-dependency validation functions:

.typokit/validators/CreateUserInput.validator.ts
export function validateCreateUserInput(input: unknown): {
success: boolean;
data?: CreateUserInput;
errors?: Array<{ path: string; expected: string; actual: unknown }>;
};

These validators enforce format checks, length bounds, required fields, and union discrimination — all derived from your type definition and JSDoc tags. They run ~10–100× faster than JSON Schema validators.

Depending on your chosen database adapter, the build generates the appropriate schema definition:

// Generated Drizzle schema
import { pgTable, uuid, varchar, timestamp, pgEnum } from "drizzle-orm/pg-core";
export const userStatusEnum = pgEnum("user_status", ["active", "suspended", "deleted"]);
export const users = pgTable("users", {
id: uuid("id").defaultRandom().primaryKey(),
email: varchar("email", { length: 255 }).notNull().unique(),
displayName: varchar("display_name", { length: 100 }).notNull(),
status: userStatusEnum("status").default("active").notNull(),
bio: varchar("bio"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});

Route contracts + type metadata produce a full OpenAPI spec:

{
"components": {
"schemas": {
"CreateUserInput": {
"type": "object",
"required": ["email", "displayName"],
"properties": {
"email": { "type": "string", "format": "email" },
"displayName": { "type": "string", "minLength": 2, "maxLength": 100 },
"status": { "enum": ["active", "suspended", "deleted"], "default": "active" },
"bio": { "type": "string" }
}
}
}
}
}

A generated client with full autocomplete for every route:

.typokit/client/index.ts
const api = createClient<UsersRoutes>({ baseUrl: "http://localhost:3000" });
const user = await api.post("/users", {
body: { email: "ada@example.com", displayName: "Ada Lovelace" }
});
// typeof user → PublicUser
// Compile-time error if body doesn't match CreateUserInput

Generated from your types with valid and invalid data variants:

.typokit/tests/users.contract.test.ts
describe("POST /users", () => {
it("accepts valid CreateUserInput", async () => {
const res = await client.post("/users", {
body: { email: "test@example.com", displayName: "Test User" }
});
expect(res.status).toBe(200);
});
it("rejects invalid email format", async () => {
const res = await client.post("/users", {
body: { email: "not-an-email", displayName: "Test" }
});
expect(res.status).toBe(400);
});
});

When you change a type, the build detects the differences and generates a migration draft:

typokit build
Schema diff detected for 'User':
+ added field 'bio' (text, nullable)
~ changed 'displayName' maxLength: 50 → 100
Migration draft written to .typokit/migrations/002_user_add_bio.sql
⚠ Review before applying — destructive changes are never auto-applied.

TypoKit’s schema-first approach works because the TypeScript compiler and build pipeline understand your types natively. Avoid patterns that bypass this:

❌ Don’t use Zod, Joi, or runtime schema libraries

Section titled “❌ Don’t use Zod, Joi, or runtime schema libraries”
// ❌ WRONG — duplicates your type definition as runtime code
import { z } from "zod";
const UserSchema = z.object({
email: z.string().email(),
displayName: z.string().min(2).max(100),
});
type User = z.infer<typeof UserSchema>;

TypoKit generates validators automatically from your interface. Runtime schema libraries create a parallel source of truth that can drift.

// ❌ WRONG — requires experimental TS features and a custom transformer
@Entity("users")
class User {
@PrimaryGeneratedColumn("uuid")
id: string;
@Column({ unique: true })
email: string;
}

Decorators are opaque to standard TypeScript tooling and AI agents. JSDoc tags are readable by any tool that can parse comments.

❌ Don’t define separate schemas for each layer

Section titled “❌ Don’t define separate schemas for each layer”
// ❌ WRONG — three definitions of the same data that will drift apart
const dbSchema = pgTable("users", { ... });
const apiSchema = z.object({ ... });
interface User { ... }

The whole point of schema-first is one definition. Let the build pipeline generate the rest.

// ✅ CORRECT — one interface, everything else is derived
/** @table users */
export interface User {
/** @id @generated uuid */
id: string;
/** @format email @unique */
email: string;
/** @minLength 2 @maxLength 100 */
displayName: string;
}
// Derived types for different contexts
export type CreateUserInput = Omit<User, "id">;
export type PublicUser = User;