Schema and parsers
schemaDescribe app data once with s, get a strict check, a TypeScript type and a JSON Schema, and turn raw text into checked values with parsers that need no model.
schemaFree in your app, Apache-2.0Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
schema describes the data your app takes in, once. A schema built with s gives you a TypeScript type, a strict check and a JSON Schema, with errors that name the path, never the value.
At a glance
import { s, type Infer } from "mainsdk/schema";
import { parse } from "mainsdk/parse";
const Invoice = s.object({
number: s.string().max(40),
total: s.number().min(0),
due: s.date(),
});
type Invoice = Infer<typeof Invoice>;
const invoice = Invoice.parse(body); // MAIN-SCHEMA-0001 lists every issue, never the value
const total = parse.number("1.234,50", { locale: "de-DE" }); // 1234.5lib/orders.test.ts
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { s } from "mainsdk/schema";
const Order = s.object({ id: s.string().uuid(), qty: s.int().min(1) });
describe("order schema", () => {
it("schema.AC1 refuses an unknown key", () => {
const result = Order.safeParse({ id: crypto.randomUUID(), qty: 1, role: "admin" });
assert.equal(result.ok, false);
if (!result.ok) assert.deepEqual(result.issues.map((i) => [i.code, i.path]), [["unknown_key", ["role"]]]);
});
});Calls
s.object(shape)schema.parse(value)schema.safeParse(value)schema.toJSONSchema()parse.text(raw)parse.match(raw, list)parse.regex(raw, re, schema)parse.json(raw, schema)parse.csv(raw, { schema })parse.lines(raw)parse.number(raw, { locale })parse.date(raw)parse.first(...parsers)check(schema, value)checkAsync(schema, value)jsonSchemaOf(schema)parse(object: text, match, regex, json, csv, lines, number, date, first, exact, pattern)s(object: schema builders)isUnsafeRegex(source)parseDuration(v, key, { allowZero? })parseSize(v)
Guarantees
Whatever the driver.
- Strict by default; an unknown key is refused, not passed on
- Issues carry a path, a code and a message, never the value
- No coercion unless you ask for it
- Parsers refuse oversized input and nested-quantifier patterns before any match runs
- Standard library only; runs in the app with no network call
- Free on every plan
Drivers
1 drivers. Select one to see its line in main.yaml.
schema:
driver: builtinChange this one line to swap providers; your calls stay the same.Built-in and local drivers integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-SCHEMA-0001 | RuntimeError responses and logs | A value does not match its schema (422); the problem's errors lists each issue's path, code and message, never the value |
MAIN-SCHEMA-0002 | RuntimeError responses and logs | The input is longer than the parser's size limit (413); nothing was matched |
MAIN-SCHEMA-0003 | RuntimeError responses and logs | A pattern has a nested quantifier, such as (a+)+, and is refused when the schema or parser is defined (500) |
MAIN-SCHEMA-0004 | RuntimeError responses and logs | A parser found no match, or no parser in parse.first matched (422); the input is not echoed |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| Strict object | Phase 1 | 0.2 | schema#core |
| JSON Schema 2020-12 from every schema | Phase 1 | 0.2 | schema#json-schema |
| Standard Schema v1 | Phase 1 | 0.2 | schema#standard |
| Opt-in coercion for forms and environment values | Phase 1 | 0.2 | schema#coerce |
| Text | Phase 1 | 0.2 | schema#parse-text |
| Regex parser with a size guard and named groups | Phase 1 | 0.2 | schema#parse-regex |
| Locale numbers and ISO 8601 dates | Phase 1 | 0.2 | schema#parse-locale |
| parse.first | Phase 1 | 0.2 | schema#parse-first |
| Schema issues in problem details and action input errors | Phase 1 | 0.2 | schema#problem-errors |
| MCP tool inputs as JSON Schema | Phase 1 | 0.2 | schema#mcp-schema |
| http.json replies checked against a schema | Phase 1 | 0.2 | schema#http-schema |
Quick start
import { s, type Infer } from "mainsdk/schema";
export const Order = s.object({
id: s.string().uuid(),
qty: s.int().min(1).max(99),
kind: s.enum(["standard", "express"]),
note: s.string().max(500).nullable(),
});
export type Order = Infer<typeof Order>;
export function readOrder(body: unknown): Order {
return Order.parse(body); // throws MAIN-SCHEMA-0001 with every issue
}parse returns a new value built from the schema, not the object you passed in. safeParse does the same without throwing, and returns { ok: false, issues } for a value that does not match.
Configuration
schema has no main.yaml key and no driver. It runs in your app, with no network call, no dependency and no setup, and it is free on every plan. The limits sit on the call: each parse function takes a maxLength, 100 000 characters by default (see Guards).
What you get
- One definition for the TypeScript type (
Infer), the strict check and the JSON Schema 2020-12 that MCP tools and model calls send. - Strict by default: unknown and missing keys are refused, and
parsecollects every issue in one pass. - Errors that say where the data is wrong by path and code, and never repeat the value.
- One schema for action input, MCP tools,
http.jsonreplies and model output, through the Standard Schema v1 interface. parseturns raw text into checked values, andparse.firsttries cheap exact and pattern parsers before it reaches a model.- Guards against oversized input, slow patterns and prototype keys.
Strict by default
An object schema refuses keys it does not list. A missing key that is not optional is refused too. parse collects every issue in one pass, so you see all of them at once.
import { s } from "mainsdk/schema";
const Order = s.object({ name: s.string(), qty: s.int().min(1) });
const result = Order.safeParse({ name: "Priya", qty: 0, role: "admin" });
// { ok: false, issues: [
// { path: ["qty"], code: "too_small", message: "Must be at least 1" },
// { path: ["role"], code: "unknown_key", message: "..." }
// ] }The issue never holds the value, so "Priya" and "admin" stay out of your logs and out of the error reply. Use .passthrough() when you want unknown keys kept in the result instead of refused.
import { s } from "mainsdk/schema";
const Loose = s.object({ name: s.string() }).passthrough();Keys named __proto__, constructor and prototype are refused everywhere, including under .passthrough() and in s.record.
Optional, nullable and refined
import { s } from "mainsdk/schema";
const Cart = s.object({
note: s.string().optional(), // the key may be missing or undefined
coupon: s.string().nullable(), // the key must be present; null is allowed
total: s.number().min(0),
}).refine((c) => c.total < 10_000, "Cart total must be under 10000");A refinement runs only after the base schema passes, and it must be synchronous. Its message is the issue's message.
Types you can use
| Schema | Accepts |
|---|---|
s.string() | Text, with .min(), .max(), .regex(), .email(), .url() and .uuid() |
s.number() | A finite number, with .min() and .max() |
s.int() | A safe integer, with .min() and .max() |
s.boolean() | true or false |
s.literal(value) | Exactly that string, number, boolean or null |
s.enum(["a", "b"]) | One of the listed strings |
s.date() | An ISO 8601 date (2026-10-27) or date-time with Z or an offset, returned as the same string |
s.array(item) | A list of one schema |
s.record(value) | An object whose values all match one schema |
s.union([a, b]) | The first option that matches |
s.discriminated("type", [a, b]) | One of several objects, chosen by the literal at type |
s.object(shape) | An object with a fixed set of keys, strict by default |
s.date refuses impossible dates such as 2026-02-30. s.number refuses NaN and infinities. Version 1 has no default values, no transforms, no async refinements and no lazy or recursive schemas. Set defaults in your code after parse.
Infer
Infer gives the type a schema accepts. Optional keys become optional properties, and nullable keys add | null.
import { s, type Infer } from "mainsdk/schema";
const Cart = s.object({
note: s.string().optional(),
coupon: s.string().nullable(),
});
type Cart = Infer<typeof Cart>; // { note?: string; coupon: string | null }JSON Schema
Every native schema emits JSON Schema 2020-12 with $schema at the root.
import { s } from "mainsdk/schema";
const Order = s.object({ id: s.string().uuid(), qty: s.int().min(1) });
const json = Order.toJSONSchema(); // strict object: required, properties, additionalProperties: falseMCP advertises each tool's input as this document, and ai.extract sends it to the model. A refinement has no JSON Schema form, so the base document stays as it is. A schema from a library that cannot emit JSON Schema is sent as { type: "object" }, and its reply is still checked.
Standard Schema
Every native schema exposes the Standard Schema v1 interface with vendor: "mainsdk". Anywhere the SDK takes a schema, you can pass a native schema, any Standard Schema value, or an object with a parse method. Zod still works through Standard Schema: pass a Zod or Valibot schema where a schema is taken, and its issues come back as { path, code, message }, with code set to the library's code or to invalid.
The places that take a schema are the action input, the ai.generate, ai.extract, ai.classify and ai.extractor calls, http.json, and the parse.regex, parse.json, parse.csv and parse.pattern parsers.
Coercion
Coercion is off by default. A string "3" against s.number() is a type issue, because a form or an environment variable may send text where you want a number. Opt in where the text comes from a form, a query string or the environment:
import { s } from "mainsdk/schema";
const Query = s.object({
page: s.coerce.int().min(1),
open: s.coerce.boolean(),
});
const q = Query.parse({ page: " 2 ", open: "true" }); // { page: 2, open: true }s.coerce.number(), s.coerce.int() and s.coerce.boolean() convert a trimmed numeric string, and booleans read "true", "false", "1" and "0". An empty string never becomes 0. To coerce every number, int and boolean in one schema, pass { coerce: true } to parse or safeParse.
Parsers
parse takes raw text, such as an email body, a CSV upload or a log line, and returns a checked value. Each parser refuses input longer than its maxLength (100 000 characters by default) before any pattern runs.
import { parse } from "mainsdk/parse";
import { s } from "mainsdk/schema";
parse.text(" Café "); // "Café", trimmed and NFC
parse.match("express ", ["standard", "Express"], { caseInsensitive: true }); // "Express"
parse.lines("a\r\n\nb\rc"); // ["a", "b", "c"]
parse.number("12,34,567.89", { locale: "en-IN" }); // 1234567.89
parse.number("1.234.567,89", { locale: "de-DE" }); // 1234567.89
parse.date("2026-10-27T10:30:00+05:30"); // a Dateparse.text(raw, { maxLength? })trims the text and normalises it to NFC.parse.match(raw, list, { caseInsensitive? })returns the entry oflistthat equals the text. It never matches partly or fuzzily, and it fails when two entries match case-insensitively.parse.regex(raw, re, schema)reads the named groups of the first match into an object and checks it with the schema, coerced, since every group is a string.parse.json(raw, schema?)reads JSON and then checks it with the schema.parse.csv(raw, { schema?, header?, delimiter? })reads RFC 4180 CSV, with quoted fields, doubled quotes and CRLF or LF line ends. Withheader: truethe first row names the columns, and a bad cell is reported at[row, "column"], with the row counted from 0 after the header.parse.lines(raw, { skipEmpty? })splits on CRLF, LF or CR, trims each line and skips empty lines unless you setskipEmpty: false.parse.number(raw, { locale })reads the separators thatIntl.NumberFormatuses for the locale, including Indian lakh grouping.parse.date(raw)accepts the same ISO 8601 forms ass.dateand returns aDate.
import { parse } from "mainsdk/parse";
import { s } from "mainsdk/schema";
const Refund = s.object({ id: s.coerce.int(), qty: s.coerce.int() });
const refund = parse.regex("Order 42 x3", /Order (?<id>\d+) x(?<qty>\d+)/, Refund);
// { id: 42, qty: 3 }Use parse.json and parse.csv with the schema that describes the rows, so bad input fails with the same codes as the rest of the SDK:
import { parse } from "mainsdk/parse";
import { s } from "mainsdk/schema";
const Row = s.object({ sku: s.string().max(20), qty: s.coerce.int().min(1) });
export function readRows(csv: string) {
return parse.csv(csv, { schema: Row, header: true }); // typed rows, or MAIN-SCHEMA-0001 at [row, "column"]
}parse.first
parse.first tries parsers in the order you give them. The first one that matches wins, and the result says how it was found: via is exact, pattern or model, and a model match carries its cost.
import { ai } from "mainsdk";
import { parse } from "mainsdk/parse";
import { s } from "mainsdk/schema";
const Refund = s.object({ id: s.coerce.int() });
const find = parse.first(
parse.exact(["refund"]), // no model, no cost
parse.pattern(/refund (?<id>\d+)/, Refund), // no model, no cost
ai.extractor(s.object({ orderId: s.string() }), { model: "anthropic/claude-sonnet-4-5", feature: "support" }),
);
const hit = await find("my order 42 never came");
// { value: { orderId: "42" }, via: "model", cost: "$0.01" }A parser that throws MAIN-SCHEMA-0001 or MAIN-SCHEMA-0004 counts as no match. Any other error stops the search and reaches your code. When no parser matches, parse.first throws MAIN-SCHEMA-0004. The model runs only when the cheaper parsers before it miss.
Guards
- Size. Every
parsefunction refuses input over itsmaxLengthwithMAIN-SCHEMA-0002before any pattern runs. The default is 100 000 characters. A string schema's.regex()checks itsmaxfirst, with 100 000 characters when none is set. - Patterns. A pattern with a nested quantifier, such as
(a+)+,(\w*)*or(a|ab+){2,}, is refused when you define it, withMAIN-SCHEMA-0003. Nothing runs against input. - Values. Issues and errors carry paths, codes and messages, never a value. A parser that finds no match does not echo the input.
- Prototypes.
__proto__,constructorandprototypeare refused as keys, so a request body cannot write to an object's prototype. - Process. Schemas and parsers run in your app with no network call.
Errors and checks
| Code | When | Fix |
|---|---|---|
MAIN-SCHEMA-0001 | A value does not match its schema (422). The problem's errors lists each issue's path, code and message, never the value | Send a value that matches the schema; read each errors entry |
MAIN-SCHEMA-0002 | The input is longer than the parser's size limit (413). Nothing was matched | Send a shorter input, or raise maxLength on purpose |
MAIN-SCHEMA-0003 | A pattern has a nested quantifier and is refused when defined (400) | Rewrite the pattern so that no repeated group sits inside another repeat |
MAIN-SCHEMA-0004 | A parser found no match, or no parser in parse.first matched (422) | Check the input's format, or add a parser or a model fallback to parse.first |
An action whose input fails its schema returns MAIN-ACTION-0001 (400) with the same errors list. An http.json reply that fails its schema returns MAIN-SCHEMA-0001, and no part of the body is in the error.
Swapping drivers
There is no driver to swap: schemas and parsers run in process. What you can change is the validator. Anywhere the SDK takes a schema you can pass a native s schema, a Standard Schema value such as Zod or Valibot, or an object with a parse method (see Standard Schema). Your calls stay the same.
Testing
Schemas run in process, so a test calls them directly. Name the test by the behaviour it proves:
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { s } from "mainsdk/schema";
const Order = s.object({ id: s.string().uuid(), qty: s.int().min(1) });
describe("order schema", () => {
it("schema.AC1 refuses an unknown key", () => {
const result = Order.safeParse({ id: crypto.randomUUID(), qty: 1, role: "admin" });
assert.equal(result.ok, false);
if (!result.ok) assert.deepEqual(result.issues.map((i) => [i.code, i.path]), [["unknown_key", ["role"]]]);
});
});FAQ
Do I still use Zod? You can. Zod and Valibot work wherever a schema is taken, through Standard Schema. New code should use s, because its JSON Schema, its issues and its guards are the ones MCP and the model verbs use.
Why is there no .default()? Version 1 keeps parse a pure check. Set the default in your code after parse returns, so the value you store is the one you wrote.
Can a schema call a database or a model? No. A refinement is synchronous and in process, so a check never waits on the network.
Why does parse not return my input object? It returns a new value built from the schema, so keys you did not declare cannot slip through, and the object you passed is never changed.
Methods on a schema
Every schema has schema.parse(value), which throws MAIN-SCHEMA-0001 for a bad value, schema.safeParse(value), which returns { ok, issues }, and schema.toJSONSchema(). For a validator you did not build with s, such as a Standard Schema or a { parse } object, use the functions below.
import { check, checkAsync, jsonSchemaOf, s } from "mainsdk";
const Order = s.object({ id: s.string(), qty: s.int().min(1) });
const input: unknown = { id: "o_1", qty: 2 };
const order = check(Order, input); // throws MAIN-SCHEMA-0001 on a bad value
const later = await checkAsync(Order, input); // for validators that run asynchronously
const json = jsonSchemaOf(Order); // the JSON Schema, or undefinedReference
s and parse are plain objects of builders and parsers, not functions: write s.string() and parse.json(raw, schema), never s(...) or parse(...). The builders are in Types you can use, and the parsers are parse.text, match, regex, json, csv, lines, number, date, first, exact and pattern.
Three small helpers from the same package read values you meet while building validators and settings.
| Call | What it does |
|---|---|
isUnsafeRegex(source) | True when a pattern's source could backtrack catastrophically. It flags a quantified group that holds a repeat, such as (a+)+ or (\w*)*, and a quantified group of alternatives where one can match what another matches, such as (a|aa)+. It is a heuristic that errs towards refusing, and it doesn't catch polynomial cases such as a*a*a*; the length cap bounds those. s.string().regex() and parse.regex use it and fail with MAIN-SCHEMA-0003 |
parseDuration(v) | A duration such as 250ms, 90s, 1.5h or 2w as milliseconds. The units are ms, s, m, h, d and w, and a number is taken as milliseconds. Anything else throws MAIN-SDK-0006 with the accepted forms in fix |
parseSize(v) | A size such as 512KB, 25MB or 1.5GB as bytes (base 1024). The units are B, KB, MB, GB and TB, and a number is taken as bytes. Anything else throws MAIN-SDK-0006 |
import { parseDuration, parseSize } from "mainsdk";
parseDuration("1.5h"); // 5_400_000
parseSize("25MB"); // 26_214_400Next
- Actions and agent tools: the
inputschema that every door validates. - AI calls:
ai.extract,ai.classify,ai.summarizeandai.extractor, which send your schema to a model. - Extract structured data from an email: regex first, a model as the fallback, and a typed invoice at the end.
- Errors and problem details: the
errorsmember and the codes above.
Was this page helpful?