- Introduce `@oh-my-pi/omptype` as a new ArkType-compatible schema validation package featuring a lazy JIT runtime, JSON Schema emission, and compatibility adapters. - Replace `arktype` across workspace packages and test utilities with `@oh-my-pi/omptype`. - Add benchmark suites, tests, and documentation for the new validation engine and adapters. - Update workspace build, test runner, and release configurations to include the new package.
6.0 KiB
omptype Guide (schema authoring in this repo)
Internal schemas use @oh-my-pi/omptype — an ArkType-compatible validator
with a lazy JIT runtime (packages/omptype). Author types with
import { type } from "@oh-my-pi/omptype".
Scope rule. Zod stays supported at the external boundary —
Tool.parametersaccepts Zod or omptype or JSON Schema, and the publicpi.zodextension API is untouched. Internal schemas use omptype.
Why omptype (perf contract)
type()construction is ~100x cheaper than arktype (no eager codegen, no node interning).- The first two calls run an interpreter; the third call JIT-compiles a specialized
validator via
new Function. Hot-path validation is tens of nanoseconds; failures allocate one small error object with lazy message building. - There is no
jitlessswitch and noscope()— lazy JIT removed the startup tax those existed to dodge. Importtypedirectly.
The detection contract (don't break it)
packages/ai/src/utils/schema/wire.ts distinguishes the three schema kinds:
- omptype = a callable function with
.toJsonSchemaand.assertmethods (isArkSchema). - Zod = a non-callable object carrying
_zod+.parse(isZodSchema). - JSON Schema = a plain object.
At the provider boundary, toolWireSchema() calls toJsonSchema(), prunes
T | undefined branches, and closes declared objects with
additionalProperties: false. Predicates (.narrow) and morphs (.pipe)
validate locally but degrade to their base schema on the wire.
Definition language (arktype-compatible subset)
| Construct | Form |
|---|---|
| Primitives | "string", "number", "boolean", "null", "undefined", "unknown", "object", "bigint" |
| Integer | "number.integer" |
| URL string | "string.url" |
| Literals | "'x'", "5", "true" |
| Unions | "'a' | 'b'", "string | null" |
| Arrays | "string[]", "(string | number)[]", [def, "[]"] |
| Bounds | "number >= 0", "0 < number <= 3600", "1 <= string <= 10" |
| Optional key | { "limit?": "number" } or value-suffix { limit: "number?" } |
| Defaults | { count: "number = 10" }, type("string[]").default(() => []) |
| Undeclared keys | "+": "reject" (fail) / "+": "delete" (strip) / default keep |
| Records | { "[string]": "number" } — NOT "Record<string, number>" |
| Runtime enums | type.enumerated(...RUNTIME_ARRAY) |
| Runtime-built object defs | type.raw({...}) (returns BaseType) |
| Keyword statics | type.number.atLeast(5).atMost(300), type.string |
Validating (same as arktype)
import { type } from "@oh-my-pi/omptype";
const out = schema(value);
if (out instanceof type.errors) {
// out.summary → human message; entries have .path (array) and .problem
throw new Error(out.summary);
}
// `out` is the validated/morphed value (defaults filled, extras stripped)
- Failure returns an
OmpErrors(array ofOmpError);type.errors === OmpErrors. - Validation is fast-fail: one error entry per failure.
- Morphs never mutate the input; when defaults/
"+": "delete"/pipes apply, a fresh object is returned. - NEVER use
.allows()for tool validation — it skips morphs/defaults/pipes. .infer/.inferInare inference-only properties.- Definition mistakes (bad DSL, illegal composition) throw
OmpTypeErrorattype()time.
Methods
.describe(d), .default(v | () => v), .or(TypeOrStringDef), .and(Type),
.array(), .atLeastLength(n) / .atMostLength(n) (string/array),
.atLeast(n) / .atMost(n) (number), .pipe(fn), .narrow(fn) (with
ctx.mustBe("...")), .allows(v), .assert(v), .toJsonSchema().
Note on .or() typing: schema and string operands infer precisely;
object-literal operands degrade — wrap them with type({...}) first.
Zod → omptype translation (for new code)
| Zod | omptype |
|---|---|
z.object({ a: ... }) |
type({ a: ... }) |
z.enum(["a","b"]) (static) |
"'a' | 'b'" |
z.enum(RUNTIME_ARRAY) |
type.enumerated(...RUNTIME_ARRAY) |
z.record(z.string(), z.number()) |
type({ "[string]": "number" }) |
.optional() |
optional key { "a?": "string" } |
.strict() / .strip() |
"+": "reject" / "+": "delete" |
.refine(fn, msg) |
.narrow((d, ctx) => fn(d) || ctx.mustBe("<expect>")) |
.transform(fn) |
.pipe(fn) |
.catch(fallback) |
type("unknown").pipe(raw => { const out = inner(raw); return out instanceof type.errors ? FALLBACK : out; }) |
z.infer<typeof S> |
typeof S.infer |
Adapters
For TypeBox-style or Zod-style authoring backed by the omptype runtime:
import { Type, type Static } from "@oh-my-pi/omptype/typebox";
import { z } from "@oh-my-pi/omptype/zod";
Both produce real omptype schemas (JIT validation, toJsonSchema, wire
detection). Use them for extension-facing surfaces; internal code authors
the string DSL directly.