10 KiB
ArkType Guide (for migrating Zod → ArkType in this repo)
Pinned to arktype 2.2.3 (workspace catalog). 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.
At the provider boundary, toolWireSchema() calls ArkType's
toJsonSchema({ target: "draft-2020-12", fallback: ctx => ctx.base }), drops
$schema, prunes the unconstrained branch emitted for T | undefined, and
closes every declared object with additionalProperties: false. Predicates and
morphs therefore validate locally but degrade to their base schema on the wire.
A T | undefined property remains required: use an optional ? key when the
model may omit it.
Runtime validation and wire emission deliberately differ for undeclared keys:
a plain ArkType object preserves extras during local validation, while
toolWireSchema() closes its declared object for provider generation.
"+": "reject" rejects locally, and the shared tool-validation pipeline strips
root and nested extras before dispatch.
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' | 'b'"` | |
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 |
b"` | |
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 |
null"` | |
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) | ctx.mustBe(""))` | |
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"). toolWireSchema()removes$schemaautomatically. If you calltoJsonSchema()directly for some other boundary, strip its$schemametadata there.
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. - Run the focused tests that exercise the migrated schema and the affected package's type check.
- Report any
.strict→"+",.refine→.narrow,.catch→morph conversion, and any file intentionally left on Zod with the reason.