Drop-in Zod API
A 1:1 drop-in for z.* — refinements,
defaults, coercion, and infer / input / output.
Migrate an existing schema by find-replacing z.
→ s. Nothing new to learn.
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 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.
Open the playground// 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 1:1 drop-in for z.* — refinements,
defaults, coercion, and infer / input / output.
Migrate an existing schema by find-replacing z.
→ s. Nothing new to learn.
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 1:1 drop-in for z.*. Migrate an existing schema by find-replacing z. → s. If you know Zod, there's nothing new to learn.
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.
Generate the DDL, run the migrations, keep your types — straight from the schema you already wrote.