The encoded and decoded sides
Schemic has one central idea: a field has two values, not one. The value you hold in TypeScript is rarely the value SurrealDB stores. A timestamp is a JavaScript Date in your code but a datetime on the wire; an id is a string in your code but a Uuid in the database. Schemic keeps both, and converts between them for you.
It does this with machinery Zod already has — its two channels — so there is nothing new to learn about how parsing works.
Two sides of every schema
Every Zod schema has two types, an input and an output. Schemic assigns each a meaning:
- The decoded side is
z.output. This is the app value — what you work with in TypeScript. Schemic exposes it asApp<typeof Table>. - The encoded side is
z.input. This is the wire value — what SurrealDB stores and returns. Schemic exposes it asWire<typeof Table>.
For a plain s.string() the two sides are identical. For a s.datetime() they differ: App is Date, Wire is DateTime. The field that bridges them is a codec.
Two operations move values across
You cross between the sides with two methods on a table definition:
decode(row)reads a database row into app values: wire → app. Use it on anything you read back from SurrealDB.encode(input)turns app values into a wire payload: app → wire. Use it on anything you write to SurrealDB.
import { Surreal } from "surrealdb";
import { User } from "./schema/user";
const db = new Surreal();
// ...connect...
// app -> wire: build the payload and write it
const [[row]] = await db.query("CREATE user CONTENT $data", {
data: User.encode({ name: "Ada", email: "ada@example.com" }),
});
// wire -> app: read the row back as app values
const ada = User.decode(row);
ada.createdAt; // DateThe direction is the thing to remember: encode to write, decode to read.
Create, update, and the two write shapes
Writes come in two flavours, and Schemic gives each its own method and type.
encode(input)produces a payload for a full insert — aCREATEorCONTENT. Fields with a database default ($default) are optional in its input, because SurrealDB fills them. Its input type isCreate<typeof Table>.encodePartial(patch)produces a partial wire payload for an update — aMERGE,UPDATE, orPATCH. Every field is optional. Its input type isUpdate<typeof Table>.
// full insert — createdAt is filled by the DB default, so omit it
await db.query("CREATE user CONTENT $data", {
data: User.encode({ name: "Ada", email: "ada@example.com" }),
});
// partial update — send only what changed
await db.query("UPDATE $id MERGE $patch", {
id: userId,
patch: User.encodePartial({ name: "Ada Lovelace" }),
});This is why there are four public types. They are the same schema seen from four angles:
| Type | Side | Shape | Use it for |
|---|---|---|---|
App<typeof T> | decoded | full | the value you read and work with |
Wire<typeof T> | encoded | full | a full row as SurrealDB stores it |
Create<typeof T> | decoded input | default fields optional | the argument to encode |
Update<typeof T> | decoded input | all fields optional | the argument to encodePartial |
Safe and async variants
Each operation has a non-throwing safe* variant that returns a result object instead of throwing on invalid input, mirroring Zod’s safeParse:
const result = User.safeDecode(row);
if (!result.success) {
// result.error is a ZodError
}safeDecode, safeEncode, and safeEncodePartial cover the three operations. Async variants — decodeAsync, encodeAsync, encodePartialAsync — exist for schemas with async refinements. Individual fields also expose .encode(value) and .decode(value) when you need to convert a single value rather than a whole row.
The wire side is what becomes the DDL
A field’s SurrealQL TYPE is its wire (encoded) type. This is the rule that explains how Zod’s own methods interact with the database:
- App-side methods leave the DDL unchanged.
refine,check,brand,readonly,describe, andmetaare native Zod methods that act on the decoded app value or its validation. They run in TypeScript and do not alter the stored type or add a database constraint —s.email().refine(...)still emitsstring ASSERT string::is_email($value). transformandpipechange only the decoded value. The stored wire type stays the input side. A baretransformis one-directional, so to write a transformed field you need a bidirectional codec via.$surreal(type, codec).
This is the dividing line behind Schemic’s method naming: non-$ methods are native Zod and act app-side; $-prefixed methods emit DDL. A wire type SurrealDB cannot represent is rejected when the DDL is emitted, and .$surreal(type, codec) is the escape hatch. See field methods for the full split.
Where to go next
- Codecs — the built-in conversions and how to write your own.
- Encode & decode rows — the task-focused how-to with the SurrealDB SDK.
- Type utilities — the full reference for
App,Wire,Create, andUpdate.