---
title: "Schema and parsers"
description: "Describe 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."
section: "interfaces"
status: "Building for 0.2 · ships as a beta API"
spec: "schema"
canonical: "https://mainsdk.dev/docs/interfaces/schema"
updated: "2026-10-28"
---

# Schema and parsers

> Describe 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.

Status: Building for 0.2 · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/schema · Updated: 2026-10-28

`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

- Status: Building for 0.2 · ships as a beta API
- Phase: 1 (Core, public beta)
- Milestone: 0.2 Core loop
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/schema.md
- Category: Developer experience

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)`

In main.yaml:

```yaml

```

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

Pick one in main.yaml with `schema.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Built in | `builtin` | yes |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-SCHEMA-0001` | runtime | 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` | runtime | The input is longer than the parser's size limit (413); nothing was matched |
| `MAIN-SCHEMA-0003` | runtime | A pattern has a nested quantifier, such as `(a+)+`, and is refused when the schema or parser is defined (500) |
| `MAIN-SCHEMA-0004` | runtime | A parser found no match, or no parser in `parse.first` matched (422); the input is not echoed |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| Strict object | 1 | 0.2 | `schema#core` |
| JSON Schema 2020-12 from every schema | 1 | 0.2 | `schema#json-schema` |
| Standard Schema v1 | 1 | 0.2 | `schema#standard` |
| Opt-in coercion for forms and environment values | 1 | 0.2 | `schema#coerce` |
| Text | 1 | 0.2 | `schema#parse-text` |
| Regex parser with a size guard and named groups | 1 | 0.2 | `schema#parse-regex` |
| Locale numbers and ISO 8601 dates | 1 | 0.2 | `schema#parse-locale` |
| parse.first | 1 | 0.2 | `schema#parse-first` |
| Schema issues in problem details and action input errors | 1 | 0.2 | `schema#problem-errors` |
| MCP tool inputs as JSON Schema | 1 | 0.2 | `schema#mcp-schema` |
| http.json replies checked against a schema | 1 | 0.2 | `schema#http-schema` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```ts title="lib/orders.ts"
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](#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 `parse` collects 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.json` replies and model output, through the Standard Schema v1 interface.
- `parse` turns raw text into checked values, and `parse.first` tries 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.

```ts title="lib/order-check.ts"
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.

```ts
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

```ts title="lib/coupon.ts"
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`.

```ts title="lib/types.ts"
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.

```ts title="lib/order-json-schema.ts"
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: false
```

MCP 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:

```ts title="app/api/search/route.ts"
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.

```ts title="lib/parse-examples.ts"
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 Date
```

- `parse.text(raw, { maxLength? })` trims the text and normalises it to NFC.
- `parse.match(raw, list, { caseInsensitive? })` returns the entry of `list` that 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. With `header: true` the 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 set `skipEmpty: false`.
- `parse.number(raw, { locale })` reads the separators that `Intl.NumberFormat` uses for the locale, including Indian lakh grouping.
- `parse.date(raw)` accepts the same ISO 8601 forms as `s.date` and returns a `Date`.

```ts title="lib/refund-line.ts"
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:

```ts title="lib/import-orders.ts"
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`.

```ts title="lib/route-support-message.ts"
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 `parse` function refuses input over its `maxLength` with `MAIN-SCHEMA-0002` before any pattern runs. The default is 100 000 characters. A string schema's `.regex()` checks its `max` first, 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, with `MAIN-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__`, `constructor` and `prototype` are 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](#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:

```ts title="lib/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"]]]);
  });
});
```

## 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.

```ts title="lib/validate.ts"
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 undefined
```

## Reference

`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](#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` |

```ts title="lib/limits.ts"
import { parseDuration, parseSize } from "mainsdk";

parseDuration("1.5h");   // 5_400_000
parseSize("25MB");       // 26_214_400
```

## Next

- [Actions and agent tools](https://mainsdk.dev/docs/interfaces/action): the `input` schema that every door validates.
- [AI calls](https://mainsdk.dev/docs/interfaces/ai): `ai.extract`, `ai.classify`, `ai.summarize` and `ai.extractor`, which send your schema to a model.
- [Extract structured data from an email](https://mainsdk.dev/docs/guides/extract-structured-data): regex first, a model as the fallback, and a typed invoice at the end.
- [Errors and problem details](https://mainsdk.dev/docs/reference/errors): the `errors` member and the codes above.
