7.3 KiB
ArkType Guide (for migrating Zod → ArkType in this repo)
Pinned to arktype 2.2.0 (installed). Verified against the installed .d.ts and runtime this
session. Author types with import { type } from "arktype".
Scope rule (READ FIRST). Zod stays supported at the external boundary —
Tool.parametersaccepts Zod or ArkType or JSON Schema, and the publicpi.zodextension API + the Zod-backedtypeboxshim are untouched. Migrate internal schemas to ArkType. If a file genuinely cannot be expressed cleanly in ArkType (see "Resilient parsing" below) and it parses an external/untrusted payload, it MAY stay on Zod — say so in your report rather than shipping broken ArkType.
The detection contract (don't break it)
packages/ai/src/utils/schema/wire.ts distinguishes the three schema kinds:
- ArkType = a callable function with
.toJsonSchemaand.assertmethods (isArkSchema). - Zod = a non-callable object carrying
_zod+.parse(isZodSchema). - JSON Schema = a plain object.
So an ArkType Type is a function. NEVER detect it via $/_arktype/__arktype markers — those
don't exist. isArkSchema, arkToWireSchema, isZodSchema, zodToWireSchema all remain exported.
Core translation table (Zod → ArkType)
| Zod | ArkType |
|---|---|
z.object({ a: ... }) |
type({ a: ... }) |
z.string() / z.number() / z.boolean() |
"string" / "number" / "boolean" |
z.number().int() |
"number.integer" |
z.literal("x") |
"'x'" ; z.literal(5) → "5" |
z.enum(["a","b"]) (static) |
`"'a' |
z.enum(RUNTIME_ARRAY) (dynamic) |
type.enumerated(...RUNTIME_ARRAY) — NOT `type(arr.join(" |
z.array(z.string()) |
"string[]" |
z.array(Item) (Item is a type) |
Item.array() |
z.union([A,B]) |
A.or(B) or `"a |
z.record(z.string(), z.number()) |
type({ "[string]": "number" }) — use the real value type, NOT "unknown" unless it was z.unknown() |
z.unknown() / z.any() |
"unknown" |
z.null() |
"null" |
z.nullable(X) |
X.or("null") or `"X |
field .optional() |
optional key: { "a?": "string" } (NOT a value method) |
string length .min(n)/.max(n) |
"string >= n" / "string <= n" / "1 <= string <= 10" |
number .min/.max/.gt/.lt |
"number >= n" / "number > n" / "1 <= number <= 10" |
| dynamic bound (runtime var) | chain methods: type("string").atLeastLength(1).atMostLength(MAX) — NOT a template string |
.describe("d") |
.describe("d") (emits JSON Schema description) |
.strict() (reject extras) |
add key "+": "reject": type({ "+": "reject", ... }) |
.strip() (drop extras — Zod default) |
add key "+": "delete" |
.passthrough() / .loose() |
drop it (ArkType keeps undeclared keys by default) |
.refine(fn, msg) |
`.narrow((d, ctx) => fn(d) |
z.infer<typeof S> |
typeof S.infer |
z.input<typeof S> |
typeof S.inferIn |
FOOTGUNS (these caused real breakage — avoid them)
- Never put
.default()on an optional?key.z.X.default(v).optional()in Zod is output-optional (default applied in code via??) → translate to an optional key, no default:"limit?": "number". Onlyz.X.default(v)without.optional()(output-required) becomesfield: type("number").default(v)(key has NO?). .default()only works as an object-property value.type("number = 0")standalone throws — use it inline (type({ count: "number = 0" })) or.default()on a non-optional key.- A described literal union emits
anyOfofconst, notenum. That is correct and validates identically; assert semantic wire properties (description, required,additionalProperties), not the exactenumvsanyOfshape. type()needs a statically-known definition. A runtime-built string (type(arr.join("|")),type(\1 <= string <= ${MAX}`)) fails TS. Usetype.enumerated(...)` / chain methods instead.- Integer ranges:
"1 <= number.integer <= 3600"(NOT"number.integer >= 1 <= 3600"). $schemais emitted bytoJsonSchema()— strip it for wire parity (delete raw.$schema).
Validating with a schema (replacing .parse / .safeParse)
ArkType Type is invoked to validate; failure returns an ArkErrors instance:
import { type } from "arktype";
const out = schema(value);
if (out instanceof type.errors) {
// out.summary -> human message; out.map(e => `${e.path}: ${e.message}`)
throw new Error(out.summary);
}
// else `out` is the validated/morphed value
.parse(x)→const out = schema(x); if (out instanceof type.errors) throw new Error(out.summary); use out;.safeParse(x).success→!(schema(x) instanceof type.errors)- NEVER use
.allows()for tool validation — it skips morphs/defaults/narrows. .infer(output) and.inferIn(input) are inference-only properties (no runtime value).
Advanced
Scopes (reusable aliases / mutually-referential schemas)
Replace a cluster of cross-referencing Zod schemas with a scope, then .export() to a module:
import { scope } from "arktype";
const myScope = scope({
inner: { id: "string" },
outer: { inner: "inner", tags: "string[]" },
});
const m = myScope.export(); // Module — m.outer, m.inner are Type instances
Use .export() — NOT .compile() (that method does not exist on a Scope).
Morphs / transforms (replacing .transform())
const n = type("string").pipe(s => Number.parseInt(s)); // validate then transform
const o = type("string").to("number.integer"); // .to(def) == .pipe(type(def))
narrow (cross-field / post-validation predicate, replacing .refine)
narrow runs AFTER all validators/morphs (output side). ctx.mustBe("<expectation>") returns false
and records must be <expectation>:
type({ action: "string", "body?": "string" })
.narrow((p, ctx) => p.action === "delete" || p.body !== undefined || ctx.mustBe("a body unless deleting"));
Resilient parsing (replacing Zod .catch(fallback))
ArkType has no built-in .catch(). For "parse, else fallback", wrap the unsafe work in a morph:
const resilient = type("unknown").pipe(raw => {
const out = innerSchema(raw);
return out instanceof type.errors ? FALLBACK : out; // never throws
});
For "missing → default", use the = default syntax ("number = 5"). If a parser relies heavily on
per-field .catch() over an untrusted external payload and the morph rewrite gets unwieldy, that file
is a candidate to stay on Zod (external-boundary exception) — note it in your report.
Defaults recap
type({ count: "number = 0", flag: "boolean = false" })— inline, output-required, wiredefault.type({ x: type("number").describe("d").default(0) })—.default()on a NON-optional key when you also need.describe().
When you finish a file
- Replace
import { z } from "zod/v4"withimport { type } from "arktype"(keepzonly if still used). - Preserve every
.describe()string and field optionality EXACTLY. - Convert every
.parse/.safeParsecall site in the file. - Do NOT run build/test/lint/format — the orchestrator runs gates once at the end.
- Report: files changed, any
.strict→"+",.refine→.narrow,.catch→morph, and any file you intentionally left on Zod (with the reason).