Drop-in Zod API
A drop-in for z.* — refinements,
defaults, coercion, and infer / input / output.
The API mirrors the z. you
already know — plus $-methods
that emit your database's DDL.
Define your schema with s.* — a drop-in for Zod. Schemic generates your database's native DDL,
validates at runtime, and runs your migrations.
TypeScript-first Zod-compatible Standard Schema MIT
But you already describe your data in TypeScript.
// schema in, native ddl out
s.* schema to generated native DDL SurrealQL PostgreSQL .
import { s, defineTable } from "@schemic/<driver>"; // Pick a database — the shape stays the same, the import + output change export const User = defineTable("user", { id: s.string(), name: s.string(), email: s.string(), createdAt: s.datetime(), }); export const User = defineTable("user", { id: s.string(), email: s.email().unique(), name: s.string().optional(), createdAt: s.datetime() .$default(surql`time::now()`) .$readonly(), }); import { defineTable, s, sqlExpr } from "@schemic/postgres"; export const user = defineTable("user", { email: s.text() .$unique() .$check(sqlExpr("email ~* '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$'")), name: s.string().optional(), createdAt: s.timestamptz().$default(sqlExpr("now()")), }); Pick a database to see the generated output.
-- Pick a database to generate native DDL. DEFINE TABLE user TYPE NORMAL SCHEMAFULL; DEFINE FIELD email ON TABLE user TYPE string ASSERT string::is_email($value); DEFINE INDEX user_email_idx ON TABLE user FIELDS email UNIQUE; DEFINE FIELD name ON TABLE user TYPE option<string>; DEFINE FIELD createdAt ON TABLE user TYPE datetime DEFAULT time::now() READONLY; CREATE TABLE "user" ( "id" text PRIMARY KEY, "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "email" text NOT NULL CHECK (email ~* '^[^@\s]+@[^@\s]+\.[^@\s]+$'), "name" text ); CREATE UNIQUE INDEX "user_email_key" ON "user" ("email"); s.email().unique() compiles to a typed FIELD + a UNIQUE INDEX. s.text().$unique().$check(…) compiles to a column + a CHECK + a UNIQUE INDEX. Pick a database to see the generated output.
// Pick a database to see decoded, end-to-end types. const limit = 20 const rows = await db.query<[unknown[]]>(surql` SELECT * FROM user WHERE createdAt > time::now() - 90d ORDER BY createdAt DESC LIMIT ${limit} `) const users = rows[0].map(User.decode) users[0]. import { pgSql, identifier } from "@schemic/postgres"; const limit = 20 const { rows } = await db.query(pgSql` SELECT * FROM ${identifier("user")} WHERE ${identifier("createdAt")} > now() - interval '90 days' ORDER BY ${identifier("createdAt")} DESC LIMIT ${limit} `) const users = rows as App<typeof user>[] users[0]. datetime → Date, ids, optionals and all. Pick a database to see the generated output.
Define your tables and fields in TypeScript — the native DDL is generated for you.
// what you get
A drop-in s.* schema, generated
DDL, a migration CLI, end-to-end types, the full
define* vocabulary, and a live round-trip
— all from one typed file.
A drop-in for z.* — refinements,
defaults, coercion, and infer / input / output.
The API mirrors the z. you
already know — plus $-methods
that emit your database's DDL.
Introspect a live database into typed schema files.
Infer row shapes straight from the schema.
Unsupported types are flagged in your editor — not at migration time.
Store any class with a typed codec — app value in, wire type out.
Diff against the running database.
// the cli
Write a migration from your schema, apply it, and check for drift against the live database — three commands, no DDL by hand.
// if you know Zod, you already know this
Go past tables and fields. Events, functions, and access rules all live in the same typed source — and generate real DDL.
Pick a database to see the generated output.
// Events, functions & access — one typed file. // Pick a database to generate native DDL. -- Select a database to generate native DDL. // if you know Zod, you already know this
Go past tables and fields. Events, functions, and access rules all live in the same typed source — and generate real DDL.
// events, functions & access — one file User.event("welcome", { when: surql`$event = 'CREATE'`, then: surql`fn::welcome($after.id)`, }) export const welcome = defineFunction("welcome", { user: s.recordId("user"), }).body(surql`CREATE log SET user = $user`) export const account = defineAccess("account") .record().signup(surql`CREATE user SET email = $email`) .duration({ session: "12h" }) DEFINE EVENT welcome ON TABLE user WHEN $event = 'CREATE' THEN fn::welcome($after.id); DEFINE FUNCTION fn::welcome($user: record<user>) { CREATE log SET user = $user }; DEFINE ACCESS account ON DATABASE TYPE RECORD SIGNUP (CREATE user SET email = $email) DURATION FOR SESSION 12h; // if you know Zod, you already know this
Go past tables and fields. Events, functions, and access rules all live in the same typed source — and generate real DDL.
import { defineTable, s, sqlExpr } from "@schemic/postgres"; export const customer = defineTable("customer", { email: s.text().$unique().$check(sqlExpr("email ~* '^[^@]+@[^@]+$'")), name: s.text(), }); export const order = defineTable("order", { customer: customer.record({ onDelete: "cascade" }), quantity: s.integer().$check(sqlExpr("quantity > 0")), unitPrice: s.numeric(10, 2), total: s.numeric(12, 2).$generated('quantity * "unitPrice"'), createdAt: s.timestamptz().$default(sqlExpr("now()")), }); CREATE TABLE "customer" ( "id" text PRIMARY KEY, "email" text NOT NULL CHECK (email ~* '^[^@]+@[^@]+$'), "name" text NOT NULL ); CREATE TABLE "order" ( "id" text PRIMARY KEY, "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "customer" text NOT NULL, "quantity" integer NOT NULL CHECK (quantity > 0), "total" numeric(12, 2) NOT NULL GENERATED ALWAYS AS (quantity * "unitPrice") STORED, "unitPrice" numeric(10, 2) NOT NULL ); CREATE UNIQUE INDEX "customer_email_key" ON "customer" ("email"); ALTER TABLE "order" ADD CONSTRAINT "order_customer_fkey" FOREIGN KEY ("customer") REFERENCES "customer" ("id") ON DELETE CASCADE; // common questions
Yes — s.* is a drop-in for Zod's z.*: your fields are Zod schemas (and Standard Schema compatible). The validators you know — .email(), .min(), object composition — work app-side exactly as in Zod, and $-prefixed methods add the database DDL on top. If you know Zod, you're already at home.
Hand-written DDL drifts from your types. Schemic keeps your schema, the generated DDL, and your TypeScript types as one source of truth — generated, never out of sync.
Three commands cover the whole loop: one writes a migration from the schema diff, one applies it, one checks for drift.
SurrealDB and Postgres are available today; more databases are on the way through drivers. Each driver maps the full define* vocabulary — tables, fields, indexes, events, functions, and access — to that database's DDL. MIT licensed.
Yes. One command introspects the live database and generates the matching s.* schema files, so you start from the database you already have and migrate forward.
No — it sits on top. You keep your database's official client for queries; Schemic owns the schema, the generated DDL, the migrations, and the row types.
Neither. Schemic is a schema + migrations toolkit. It owns the schema, the generated DDL, and the migrations; pair it with your database's query tools.
Always. Schemic owns the schema and migrations, not your queries — use your database's client directly, and compose raw expressions into defaults, events, permissions, and functions where a driver supports it.
// set up with ai
Let your AI coding agent set up Schemic for you. Paste this prompt into Claude Code, Cursor, or any agent — it installs and configures the right driver for your database, brings your schema into TypeScript (new or existing), and prepares your first migration for you to review and run, explaining each step.
Generate the DDL, run the migrations, keep your types — straight from the schema you already wrote.