Sign In

@ultimat3/schema

Package Overview
Dependencies
Maintainers
1
Versions
19
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@ultimat3/schema - npm Package Compare versions

Comparing version
1.2.0
to
2.0.0
+90
CLAUDE.md
# @ultimat3/schema — agent notes
Tier 0. **Imports no `@ultimat3/*` package — not even `@ultimat3/core`.**
| Rule | |
|---|---|
| Deps | none (`bun-types` only) |
| Errors | `SchemaError` mirrors `UltimateError` field-for-field; keep `Symbol.for('ultimate.error')` |
| New validator | add to `validators.ts` **and** `TNamespace` **and** `t.ts` **and** `json-schema.ts` |
| IR | every schema carries `.node: SchemaNode`; generators read that, never the closure |
| **Issue messages** | the shape of the rejected value, **never its content** — see `describe-value.ts` |
| Coercion | HTTP boundary only — never call it from actions, jobs or MCP |
| Exports | explicit in `src/index.ts`; no `export *`; a namespace member and its free function ship together (`t.nullable`/`nullableSchema`) |
| Re-exports | `action`, `query`, `jobs`, `entity` re-export `t` verbatim so an authoring file imports one package — never let them wrap or copy it |
Module order (no cycles):
`describe-value → node → builder → money-value → validators → discriminated-union → provider → t`.
`standard.ts` and `errors.ts` depend on nothing but each other.
**An issue message is a public surface.** `@ultimat3/http` folds it into `X_BODY_INVALID`'s `cause`,
which is returned to the caller AND interpolated into the log line — and core's logger redacts by
KEY, so a value baked into a string has no key left to redact. `'password'` being in `redactedKeys`
did not help: `expected(…, value)` had already written `received "hunter2"` before the logger saw
it. Every rejected value goes through `describeValue`, which reports length and type and nothing
else. No dev flag re-enables the echo — one misconfigured environment is the same breach, and a dev
overlay already holds the raw body. `describe-value.test.ts` is the enforcement.
`X_SCHEMA_DISCRIMINANT_INVALID` is thrown where a `discriminatedUnion` is BUILT, not where a value
is parsed. A member with no literal at the discriminant, or a second member claiming a tag the
first already owns, can never be routed to — so it is wrong for every input, and the first import
of the authoring file is the earliest honest place to say so.
`SCHEMA_ERROR_CODES` in `errors.ts` is data, not a `registerErrorCodes()` call — this package is
tier 0 and cannot import `@ultimat3/core` to reach it. `@ultimat3/core`'s `schema-error-codes.ts`
carries a duplicate of these titles and registers them unconditionally, so every process gets the
real titles just by importing core. Add a code here **and** update that duplicate in the same
change — `schema-error-codes-pin.test.ts` in `@ultimat3/cli` fails the build if they disagree.
`MoneyValue` in `money-value.ts` — its own file, because it is the only builtin whose *shape* other
packages alias — is the framework's **one** declaration of a money value. Tier 0 is
the only tier every package may import, and `@ultimat3/money`'s `Money` and `@ultimat3/entity`'s
`MoneyValue` are aliases of it. Never let either restate the shape: it was three structural copies,
entity's had a `bigint` `minor`, and a row that layer decoded then failed both `t.money` and
`JSON.stringify`. `minor` stays a `number` for the same reason it is a `number` here — this node is
the OpenAPI contract, and money crosses every wire the framework projects.
`MoneyValue.scale` is the **optional** decimal exponent `minor` counts in, `0…MAX_MONEY_SCALE`
(15, the last power of ten that is itself a safe integer). Absent means the currency's own minor
unit, which is every value that predates it — so `{ minor, currency }` parses to exactly
`{ minor, currency }`, key for key, and the validator adds nothing. What a legal scale is lives in
`isMoneyScale` here and nowhere else; `@ultimat3/money` imports it rather than restating the
bound. Adding it to the type means adding it in three more places in the same change — the node's
`properties`, `json-schema.ts` (optional, never `required`, or a generated client refuses a value
this validator accepts) and `coerce.ts` (a query string carries it as text like everything else).
`MoneyValue.currency` is bounded by `isCurrencyCode`, `isMoneyScale`'s twin, over
`CURRENCY_CODE_PATTERN` — the pattern **source**, exported because the two projections that cannot
call a predicate need the string: `json-schema.ts`'s published `pattern` and `@ultimat3/entity`'s
Postgres `~` CHECK (`currencyCheck`). It was four copies of `^[A-Z]{3}$` across three packages,
each individually correct, and only a psql session would have seen them diverge. Keep the pattern
inside the syntax ECMAScript, JSON Schema and POSIX ERE spell identically — anchors, a literal
class, a bounded repetition. `@ultimat3/entity`'s `currency-check.live.test.ts` is what proves a
real server still reads it the way the predicate does; a `\d` or a lookahead is where that stops.
`t` delegates through `schemaProvider()` on every property access — that is what makes
`configureSchemaProvider()` work for modules that already imported `t`. Do not cache members.
```bash
bun test # from packages/schema
bun run typecheck
```
Gotchas:
- `Schema<In, Out>` splits input from output: `.default()` makes the key optional on input and
present on output. Object key optionality is derived from that — don't hand-roll it.
- `AnySchema = Schema<unknown, unknown>` is the general constraint. Never `any`.
- Unknown object keys are dropped by design; JSON Schema says `additionalProperties: false`.
- Adding a `SchemaKind` means updating `json-schema.ts` and `coerce.ts` in the same commit.
- **Prefer a new `SchemaNode` FIELD to a new `SchemaKind`.** Every consumer that switches on `kind`
has a `default:` that degrades quietly — `json-schema.ts` emits `{}`, `coerce.ts` passes the raw
value through, `@ultimat3/action`'s `sample-input.ts` answers `null` — and they live in packages
a schema change is not allowed to edit. `refinements` and `discriminant` are both fields for that
reason: a refined string still reads as a string, and a discriminated union still reads as a
union, everywhere. `lazy` and `tuple` cannot be — which is why neither has shipped.
- A refinement is carried as a **declaration** (`name`, `message`, `path`), never a closure: a
predicate cannot cross into OpenAPI or an MCP tool schema, and `refine`'s `message` is rendered
verbatim on both — so it states the rule and never interpolates a value.
- `@ultimat3/action`'s `sampleInput` does not read `refinements`, so a contract test over a refined
schema reports `X_CONTRACT_DRIFT` exactly as it already does for a bare `pattern`. Documented
there; fixing it is that package's change, not this one's.
// Single responsibility: render a REJECTED value as its shape, and the `expected X, received Y`
// line every builtin validator fails with. Its whole job is the rule in `describeValue`'s doc
// comment — the rejected value's content never appears in an issue message. Its own file so that
// rule has one place to live and one test file to enforce it.
/**
* The shape of a rejected value — **never its content**.
*
* WHY: an issue message is not a private diagnostic. `@ultimat3/http` folds these messages into
* `X_BODY_INVALID`'s `cause`, which is returned to the caller AND written to the log line; the
* logger redacts `fields`/`contextFields` by key, and a value baked into a message string has no
* key left to redact. Echoing the value meant a signup form with a password-strength rule wrote
* every mistyped password to the central log index in cleartext (30-day retention) and into the
* user's own network tab — same for a card number, an SSN, or an API key pasted in the wrong box.
*
* So: length and type, which is what a min/max/type violation actually needs, and nothing else.
* There is no dev-only escape hatch on purpose — a flag is one misconfigured environment away
* from being the same breach, and a dev overlay already holds the raw request body.
*
* Constants are exempt only where they carry no caller data: `undefined`, `null`, `NaN` and the
* infinities name themselves because "received a number" for a `NaN` reads as a lie.
*/
export function describeValue(value: unknown): string {
if (value === undefined) return 'undefined';
if (value === null) return 'null';
switch (typeof value) {
case 'string':
// Code units, not code points: the length checks in `validators.ts` use `.length` too, so a
// message quoting a different count than the rule that rejected it would send an agent
// chasing an off-by-one that is not there.
return countOf(value.length, 'string', 'character');
case 'number':
return describeNumber(value);
case 'boolean':
return 'a boolean';
case 'bigint':
return 'a bigint';
case 'symbol':
return 'a symbol';
case 'function':
return 'a function';
default:
break;
}
if (Array.isArray(value)) return countOf(value.length, 'array', 'item');
// `getTime()` rather than a value: an invalid Date is the one Date fact a caller can act on.
if (value instanceof Date) return Number.isNaN(value.getTime()) ? 'an invalid Date' : 'a Date';
return 'an object';
}
function describeNumber(value: number): string {
if (Number.isNaN(value)) return 'NaN';
if (value === Number.POSITIVE_INFINITY) return 'Infinity';
if (value === Number.NEGATIVE_INFINITY) return '-Infinity';
return 'a number';
}
function countOf(size: number, noun: string, unit: string): string {
if (size === 0) return `an empty ${noun}`;
const article = noun === 'array' ? 'an' : 'a';
return `${article} ${noun} of ${size} ${unit}${size === 1 ? '' : 's'}`;
}
/**
* `expected a uuid, received a string of 3 characters` — the message an agent can act on without
* guessing, and without the value ever leaving the process. `what` is authored by the schema, so
* it may say anything; the second half is `describeValue` and may not.
*/
export function expected(what: string, value: unknown): string {
return `expected ${what}, received ${describeValue(value)}`;
}
// Single responsibility: a union that dispatches on one literal key instead of trying every
// member. Its own file because the dispatch table and its two authoring refusals are the whole of
// it, and `validators.ts` is already at the size where one more composite stops being readable.
import {
type AnySchema,
type Check,
checkOf,
fail,
isPlainObject,
makeSchema,
type Schema,
} from './builder';
import { expected } from './describe-value';
import { DiscriminantInvalidError } from './errors';
import type { SchemaNode } from './node';
import type { InferInput, InferOutput } from './standard';
/**
* What a tag can be — exactly what `SchemaNode.literal` and `SchemaNode.values` can hold. Spelled
* out rather than left `unknown` because these reach a `cause:` through `String()`, and a value an
* app controls reaching a refusal's own text is how three constructors learned to throw instead
* of refusing (`bun run error-render`).
*/
type SchemaTag = string | number | boolean | null | undefined;
/** The literal(s) a member declares at the discriminant, or `undefined` if it declares none. */
function declaredValues(node: SchemaNode, discriminant: string): readonly SchemaTag[] | undefined {
const child = node.properties?.[discriminant];
if (child === undefined) return undefined;
// `kind` gates rather than `literal !== undefined`: `t.literal(false)` is a legal discriminant
// and a truthiness test would have called it undeclared.
if (child.kind === 'literal') return [child.literal];
// An enum member is a legal discriminant too — one branch owning several codes is a real shape
// (`status: t.enum(['queued', 'running'])`), and refusing it would force a duplicated branch.
if (child.kind === 'enum' && child.values !== undefined && child.values.length > 0) {
return [...child.values];
}
return undefined;
}
function refuseUndeclared(discriminant: string, index: number, kind: string): never {
throw new DiscriminantInvalidError({
cause: `member #${index} of discriminatedUnion("${discriminant}") — IR kind "${kind}" — declares no literal at "${discriminant}"`,
fix: `give member #${index} a literal discriminant, t.object({ ${discriminant}: t.literal('…'), … }), or use t.union(...) if the members share no key`,
meta: { discriminant, index, kind },
});
}
function refuseDuplicate(discriminant: string, index: number, tag: SchemaTag): never {
throw new DiscriminantInvalidError({
cause: `member #${index} of discriminatedUnion("${discriminant}") claims the tag ${String(tag)}, which an earlier member already claims`,
fix: `give member #${index} its own value at "${discriminant}", or merge the two members into one`,
meta: { discriminant, index, tag: String(tag) },
});
}
/** `post | page | 2 | false` — every accepted tag, in declaration order. Developer data only. */
function renderKnown(known: readonly SchemaTag[]): string {
return known.map((value) => String(value)).join(' | ');
}
/**
* A union whose branch is chosen by one key, not by trying every member in turn.
*
* Two things `t.union(...)` cannot do. The message: a failure reports the branch the discriminant
* NAMED — `t.union` reports every branch's reasons at once, so a typo in a `post` body arrived as
* N contradictory complaints and the field that was actually wrong was named in none of them. The
* IR: `discriminator.propertyName` reaches OpenAPI and the MCP tool schema, so a code generator
* emits one tagged type instead of an untagged `anyOf`.
*
* The node stays `kind: 'union'` with a `discriminant` beside it — see `node.ts` for why a new
* `SchemaKind` would have degraded silently in every consumer that already handles unions.
*/
export function discriminatedUnionSchema<S extends readonly [AnySchema, ...AnySchema[]]>(
discriminant: string,
...members: S
): Schema<InferInput<S[number]>, InferOutput<S[number]>> {
type Out = InferOutput<S[number]>;
const branches = new Map<unknown, Check<Out>>();
const known: SchemaTag[] = [];
for (const [index, member] of members.entries()) {
const values = declaredValues(member.node, discriminant);
if (values === undefined) refuseUndeclared(discriminant, index, member.node.kind);
const check = checkOf(member) as Check<Out>;
for (const value of values) {
// Letting the first declaration win would leave a member that can never run, which is the
// same defect as an unreachable case and is invisible until the wrong branch validates.
if (branches.has(value)) refuseDuplicate(discriminant, index, value);
branches.set(value, check);
known.push(value);
}
}
const node: SchemaNode = {
kind: 'union',
discriminant,
anyOf: members.map((member) => member.node),
};
return makeSchema<InferInput<S[number]>, Out>(node, (value, path) => {
if (!isPlainObject(value)) {
return fail(path, expected(`an object with a "${discriminant}" discriminant`, value));
}
const branch = branches.get(value[discriminant]);
if (branch === undefined) {
return fail(
[...path, discriminant],
expected(`one of ${renderKnown(known)}`, value[discriminant]),
);
}
// The named branch's issues, and only those: the caller already said which shape they meant,
// so the other branches' complaints are noise that hides the one real field error.
return branch(value, path);
});
}
// Single responsibility: the framework's ONE money declaration and the validator that guards it.
// Split out of `validators.ts` because it is the only builtin whose *shape* other packages alias
// — `@ultimat3/money`'s `Money`, `@ultimat3/entity`'s `MoneyValue` — so it earns a file a reader
// can open by name instead of scrolling to.
import { fail, failWith, isPlainObject, makeSchema, pass, type Schema } from './builder';
import { expected } from './describe-value';
import type { StandardIssue } from './standard';
/**
* The shape of an ISO 4217 alphabetic code, as a pattern SOURCE — the one declaration every
* projection of that bound derives from. It is a string rather than a `RegExp` because the two
* surfaces that cannot call a predicate need the source itself: `json-schema.ts` emits it as the
* `pattern` of the published OpenAPI contract, and `@ultimat3/entity`'s `currencyCheck` emits it
* inside a Postgres `~` CHECK so a psql session cannot write a code the app would refuse to read.
*
* Keep it inside the syntax ECMAScript, JSON Schema and POSIX ERE spell identically — anchors,
* a literal character class, a bounded repetition. A construct only one of the three understands
* (`\d`, a lookahead, a non-greedy quantifier) makes the CHECK stop meaning what `isCurrencyCode`
* means, and a real server is the first thing that says so.
*/
export const CURRENCY_CODE_PATTERN = '^[A-Z]{3}$';
const CURRENCY_RE = new RegExp(CURRENCY_CODE_PATTERN);
/**
* The framework's ONE declaration of a money value. `@ultimat3/money`'s `Money` and
* `@ultimat3/entity`'s `MoneyValue` are aliases of this type, not copies of its shape — three
* structural restatements are how `minor` became a `number` here and a `bigint` there, which made
* a row the entity layer produced fail `t.money` and throw inside `JSON.stringify`.
*
* It lives at tier 0 because that is the only tier every other package may import, and `number`
* rather than `bigint` because money crosses the wire on every surface this framework projects —
* `JSON.stringify` refuses a bigint, and this node is also the OpenAPI contract. A value past
* `Number.MAX_SAFE_INTEGER` is refused HERE, at the boundary, with the field path — and again
* where it is decoded; it is never widened.
*
* Never a float, and never an amount without its currency.
*/
export interface MoneyValue {
readonly minor: number;
readonly currency: string;
/**
* Decimal places `minor` counts, when they are not the currency's own. Absent — the shape every
* existing value and every existing row still has — means the currency's natural minor unit: 2
* for USD, 0 for JPY, 3 for KWD. `{ minor: 2, currency: 'USD', scale: 6 }` is $0.000002.
*
* It exists because a cents-only value could not name a sub-cent amount at all, so the one
* place that needed one — a model call costing $0.00016 — rounded it up to a whole cent and
* reported 62x the real spend. The alternative was a second money type, which is the axiom-1
* violation this declaration exists to prevent.
*/
readonly scale?: number;
}
/**
* The largest decimal exponent a money value may carry. 10^15 is the last power of ten that is
* itself a safe integer, so a finer scale could not name its own unit inside the range `minor` is
* already checked against.
*/
export const MAX_MONEY_SCALE = 15;
/**
* What a legal `MoneyValue.currency` is — declared once, here, beside the type that carries it and
* beside `isMoneyScale`, which is the twin of this predicate and the precedent for it. Takes
* `unknown` because every caller is a boundary: a row off a `char(3)` column, a body off the wire,
* a `registerCurrency` argument from an untyped caller. `String(value).test(…)` on a symbol throws
* where a refusal was due, so the `typeof` half belongs in here rather than at each call.
*/
export function isCurrencyCode(value: unknown): value is string {
return typeof value === 'string' && CURRENCY_RE.test(value);
}
/** What a legal `MoneyValue.scale` is — declared once, here, beside the type that carries it. */
export function isMoneyScale(value: unknown): value is number {
return (
typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= MAX_MONEY_SCALE
);
}
export const moneySchema: Schema<MoneyValue, MoneyValue> = makeSchema<MoneyValue, MoneyValue>(
{
kind: 'money',
description: 'integer minor units plus an ISO 4217 currency code',
properties: {
minor: {
kind: 'number',
integer: true,
minimum: -Number.MAX_SAFE_INTEGER,
maximum: Number.MAX_SAFE_INTEGER,
},
currency: { kind: 'string', pattern: CURRENCY_CODE_PATTERN },
scale: {
kind: 'number',
integer: true,
optional: true,
minimum: 0,
maximum: MAX_MONEY_SCALE,
description: 'decimal places `minor` counts; absent means the currency’s own',
},
},
},
(value, path) => {
if (!isPlainObject(value)) return fail(path, expected('a Money object', value));
const minor = value['minor'];
const currency = value['currency'];
const scale = value['scale'];
const issues: StandardIssue[] = [];
// Safe, not merely whole: `money()` and `entity`'s `parseMinor` both demand a safe integer, so
// `Number.isInteger` here let 2^53 through the boundary as a 200 and failed at the row write
// as a 500 — the same value refused twice, once with a field path and once without.
if (typeof minor !== 'number' || !Number.isSafeInteger(minor)) {
issues.push({
message: expected('a safe integer number of minor units', minor),
path: [...path, 'minor'],
});
}
if (!isCurrencyCode(currency)) {
issues.push({
message: expected('a 3-letter ISO 4217 code', currency),
path: [...path, 'currency'],
});
}
if (scale !== undefined && !isMoneyScale(scale)) {
issues.push({
message: expected(
`a whole number of decimal places between 0 and ${MAX_MONEY_SCALE}`,
scale,
),
path: [...path, 'scale'],
});
}
if (issues.length > 0) return failWith(issues);
// The key is carried only when it was sent: a value at the currency's own scale must
// round-trip byte-for-byte, or every stored amount in every app changes shape on one parse.
return pass({
minor: minor as number,
currency: currency as string,
...(scale === undefined ? {} : { scale: scale as number }),
});
},
);
+2
-1
{
"name": "@ultimat3/schema",
"version": "1.2.0",
"version": "2.0.0",
"description": "Ultimate's validation seam: Standard Schema interface, the t namespace, JSON Schema output",

@@ -22,2 +22,3 @@ "license": "MIT",

"!src/**/*.test.ts",
"CLAUDE.md",
"README.md",

@@ -24,0 +25,0 @@ "LICENSE"

@@ -12,3 +12,5 @@ # ✅ @ultimat3/schema

| the introspectable IR every generator walks | `node.ts` |
| `Schema` factory, `Infer` machinery | `builder.ts` |
| `Schema` factory, `Infer` machinery, `.refine()` | `builder.ts` |
| how a rejected value is described — its shape, never its content | `describe-value.ts` |
| a union routed by one literal key | `discriminated-union.ts` |
| `configureSchemaProvider()` — the swap point | `provider.ts` |

@@ -30,2 +32,3 @@ | schema → JSON Schema (OpenAPI + MCP) | `json-schema.ts` |

price: t.money, // { minor: 1999, currency: 'EUR' } — never a float
// { minor: 2, currency: 'USD', scale: 6 } is $0.000002
timeZone: t.timezone, // real IANA validation, not an annotation

@@ -40,3 +43,3 @@ cursor: t.optional(t.cursor),

|---|---|---|
| `string` `number` `boolean` `date` `uuid` `email` `url` | `object` `array` `enum` `literal` `union` `record` `optional` `nullable` | `.default(v)` `.optional()` `.nullable()` `.describe(s)` |
| `string` `number` `boolean` `date` `uuid` `email` `url` | `object` `array` `enum` `literal` `union` `discriminatedUnion` `record` `optional` `nullable` `refine` | `.default(v)` `.optional()` `.nullable()` `.describe(s)` `.refine(r)` |
| `money` `timezone` `locale` `slug` `cursor` | `object(...).extend/.pick/.omit` | `string.min/.max/.pattern`, `number.min/.max/.int` |

@@ -49,2 +52,42 @@

### Cross-field rules live on the schema
A rule the IR cannot state structurally still belongs to the schema, or it moves into a handler and
disappears from `openapi.json`, the MCP tool schema, the typed client and every form binding at
once — axiom 2 broken for every such rule in the product.
```ts
const booking = t.object({ startDate: t.date, endDate: t.date }).refine({
name: 'end-after-start', // stable id → `x-ultimate-refinements`
message: 'endDate must be after startDate', // the rule, never the value
path: ['endDate'], // which field the issue lands on
check: (value) => value.endDate > value.startDate,
});
```
The predicate runs on the **parsed** output and only after the shape passed, so it compares coerced
values and never defends against a type the schema already refused. What ships in the IR is the
declaration, not the closure: `node.refinements`, projected as an `x-ultimate-refinements`
extension **and** appended to `description`, which is the only field an LLM reading a tool schema
is guaranteed to see. `.refine()` returns a plain `Schema`, so refining after `extend`/`pick`/`omit`
— which rebuild from the shape and would drop the rule — is a type error rather than a comment.
### `discriminatedUnion` names the branch it judged
```ts
const body = t.discriminatedUnion(
'kind',
t.object({ kind: t.literal('post'), slug: t.slug }),
t.object({ kind: t.literal('page'), title: t.string.min(3) }),
);
```
`t.union` reports every member's reasons at once, so one bad field in a `post` body arrives as N
contradictory complaints naming fields the caller never sent. This routes on `kind` first and
reports that branch's issues only. A member with no literal (or enum) at the discriminant, or two
members claiming one tag, is `X_SCHEMA_DISCRIMINANT_INVALID` **where the union is built** — a
branch nothing can route to is wrong for every input, not for one request. The node stays
`kind: 'union'` with a `discriminant` beside it, so every existing IR consumer keeps working, and
JSON Schema gains `discriminator: { propertyName }`.
**You rarely import this package.** `action`, `query`, `jobs` and `entity` each re-export the same

@@ -63,6 +106,14 @@ `t`, so an authoring file imports its primitive and nothing else. Import `@ultimat3/schema` directly

X_VALIDATION_FAILED: value did not match its schema
cause: postId: expected a uuid, received "abc"; notify: expected a boolean, received "yes"
cause: postId: expected a uuid, received a string of 3 characters; notify: expected a boolean, received a string of 3 characters
fix: send input with the field(s) named in cause corrected to the expected type
```
**An issue message names the shape of the rejected value, never its content**, and `received` on
the issue is empty for the same reason. `@ultimat3/http` folds these messages into
`X_BODY_INVALID`'s `cause`, which is returned to the caller *and* written to the log line — where
the logger redacts by key and a value baked into a string has no key left to redact. Echoing the
value meant a password-strength rule wrote every mistyped password to the central log index in
cleartext. There is no dev-only escape hatch: a flag is one misconfigured environment away from
being the same breach. `describe-value.ts` owns the rule and `describe-value.test.ts` enforces it.
`error.issues` is `{ path, expected, received, message }[]` with paths like `items[0].price`;

@@ -69,0 +120,0 @@ `formatIssues()` renders one line per issue for the dev overlay. `validate()` never throws and

// Single responsibility: the `Schema` type every builtin validator returns, and the factory
// that turns a check function plus an IR node into a Standard-Schema-conforming object.
import { ValidationFailedError, type ValidationIssue } from './errors';
import type { SchemaNode } from './node';
import { describeValue } from './describe-value';
import { SchemaError, ValidationFailedError, type ValidationIssue } from './errors';
import type { SchemaNode, SchemaRefinement } from './node';
import {

@@ -45,17 +46,24 @@ formatPath,

/** `expected uuid, received "abc"` — the message an agent can act on without guessing. */
export function expected(what: string, value: unknown): string {
return `expected ${what}, received ${describeValue(value)}`;
/** An object with own keys — not null, not an array. The gate every object-ish check opens with. */
export function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export function describeValue(value: unknown): string {
if (value === undefined) return 'undefined';
if (value === null) return 'null';
if (typeof value === 'string') return JSON.stringify(value);
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
if (Array.isArray(value)) return `array(${value.length})`;
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? 'Date(Invalid Date)' : `Date(${value.toISOString()})`;
}
return typeof value;
/**
* A rule the IR cannot state structurally — `endDate > startDate`, `total === sum(lines)`. It
* lives on the schema rather than in a handler so it reaches OpenAPI, the MCP tool schema, the
* typed client and the form binding from the one declaration, like every other constraint.
*
* `message` is authored by the developer and rendered verbatim, so it must state the RULE and
* never the value — see `describe-value.ts` for why an issue message is a public surface.
*/
export interface Refinement<Out> {
/** Stable machine id, kebab-case: `end-after-start`. Projected as `x-ultimate-refinements`. */
readonly name: string;
/** The rule as a sentence, e.g. `endDate must be after startDate`. Never interpolate a value. */
readonly message: string;
/** Runs on the PARSED output, so a cross-field rule compares coerced values, not raw input. */
readonly check: (value: Out) => boolean;
/** Which field the issue is reported against. Absent reports it against the object itself. */
readonly path?: readonly string[] | undefined;
}

@@ -77,2 +85,8 @@

describe(description: string): Schema<In, Out>;
/**
* Attach a cross-field rule. Returns a plain `Schema` on purpose — `extend`/`pick`/`omit`
* rebuild from the shape and would silently drop the refinement, so refining last is a type
* error to get wrong rather than a comment asking you not to.
*/
refine(refinement: Refinement<Out>): Schema<In, Out>;
}

@@ -107,2 +121,38 @@

/**
* One FRESH copy of a default per parse. `.default([])` handed every parse the same array, so a
* handler pushing onto it made that push the next request's starting value for the life of the
* process — cross-request data bleed declared in a schema.
*
* Primitives are immutable, so they are still shared and cost nothing. Everything else is decided
* ONCE, here at declaration: a value `structuredClone` can copy is copied per parse, and one it
* cannot is refused at the first import of the authoring file — the same "wrong for every input,
* so say so where it is written" rule `X_SCHEMA_DISCRIMINANT_INVALID` follows. What that refuses
* was never a working declaration: `node.default` is published as JSON, so a default carrying a
* function or a symbol could not reach OpenAPI either.
*
* Known narrow cost: a class instance clones to a plain object, so a default that relied on its
* prototype loses it. Schema defaults are wire values, which is the only thing `node.default` can
* publish, so that shape was already outside what a default may mean.
*/
function defaultFactory<Out>(fallback: Out): () => Out {
if (fallback === null || typeof fallback !== 'object') return () => fallback;
try {
structuredClone(fallback);
} catch {
throw new SchemaError({
code: 'X_SCHEMA_DEFAULT_UNSHAREABLE',
cause: `default() received ${describeValue(fallback)}, which structuredClone cannot copy`,
fix: 'pass a JSON-shaped default (plain object, array, Date, Map, Set), or drop .default() and answer the absent value in the handler',
});
}
return () => structuredClone(fallback);
}
/** The default declaration, dropped — for a wrapper that can no longer reach it. */
function withoutDefault(node: SchemaNode): SchemaNode {
const { hasDefault: _hasDefault, default: _default, ...rest } = node;
return rest;
}
function toIssues(result: CheckErr): readonly ValidationIssue[] {

@@ -139,3 +189,6 @@ return result.issues.map((issue) => ({

return makeSchema<In | undefined, Out | undefined>(
{ ...node, optional: true },
// `.default(x).optional()` published `default: x` while `parse` answered `undefined`:
// this wrapper short-circuits `undefined` before the default is ever reached, so a client
// honouring the published default assumed a value the server never produced.
withoutDefault({ ...node, optional: true }),
(value, path) => (value === undefined ? pass(undefined) : check(value, path)),

@@ -150,5 +203,8 @@ );

default(fallback: Out): Schema<In | undefined, Out> {
const fresh = defaultFactory(fallback);
return makeSchema<In | undefined, Out>(
// The node keeps the DECLARATION, never a copy: `node.default` is what OpenAPI, the MCP
// tool schema and the typed client publish, and they describe what was written.
{ ...node, hasDefault: true, default: fallback },
(value, path) => (value === undefined ? pass(fallback) : check(value, path)),
(value, path) => (value === undefined ? pass(fresh()) : check(value, path)),
);

@@ -159,2 +215,21 @@ },

},
refine(refinement: Refinement<Out>): Schema<In, Out> {
const declared: SchemaRefinement = {
name: refinement.name,
message: refinement.message,
...(refinement.path === undefined ? {} : { path: [...refinement.path] }),
};
return makeSchema<In, Out>(
{ ...node, refinements: [...(node.refinements ?? []), declared] },
(value, path) => {
// Shape first: a predicate written against `Out` must never be handed an unparsed
// value, or every refinement grows a defensive typeof the schema already performed.
const result = check(value, path);
if (!result.ok) return result;
return refinement.check(result.value)
? result
: fail([...path, ...(refinement.path ?? [])], refinement.message);
},
);
},
};

@@ -161,0 +236,0 @@ return schema;

@@ -11,2 +11,18 @@ // Single responsibility: HTTP-boundary coercion. Kept out of validation on purpose — only the

/** A numeric string as a number, or `undefined` for anything that is not confidently one. */
function numeric(raw: unknown): number | undefined {
if (typeof raw !== 'string' || raw.trim() === '') return undefined;
const value = Number(raw);
return Number.isFinite(value) ? value : undefined;
}
/** A truthy/falsy query-string spelling as a boolean, or the raw value when it is neither. */
function booleanish(raw: unknown): unknown {
if (typeof raw !== 'string') return raw;
const lowered = raw.toLowerCase();
if (TRUE_VALUES.has(lowered)) return true;
if (FALSE_VALUES.has(lowered)) return false;
return raw;
}
export type QuerySource =

@@ -29,7 +45,13 @@ | URLSearchParams

}
case 'boolean': {
case 'boolean':
return booleanish(raw);
case 'literal': {
// Toward the literal's OWN type, because `literalSchema` compares with `===`: without this
// a numeric or boolean `t.literal` was unsatisfiable over its own GET route — `t.literal(2)`
// received `"2"` and the endpoint 400d on every request — while the identical declaration
// worked over an action's JSON body and over MCP. A string literal needs nothing, which is
// exactly why the gap read as arbitrary.
if (typeof raw !== 'string') return raw;
const lowered = raw.toLowerCase();
if (TRUE_VALUES.has(lowered)) return true;
if (FALSE_VALUES.has(lowered)) return false;
if (typeof node.literal === 'number') return numeric(raw) ?? raw;
if (typeof node.literal === 'boolean') return booleanish(raw);
return raw;

@@ -49,3 +71,7 @@ }

if (typeof raw !== 'object' || node.valueNode === undefined) return raw;
const out: Record<string, unknown> = {};
// A null prototype for the reason `recordSchema` uses one: on a `{}` literal, assigning
// `out['__proto__']` hits the `Object.prototype` SETTER and the key vanishes, so the
// record validator's deliberate refusal of it never ran — the key was reported absent
// rather than rejected, on the one path (HTTP query) where it is caller-controlled.
const out: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {

@@ -68,4 +94,12 @@ out[key] = coerceNode(node.valueNode, value);

const source = raw as Record<string, unknown>;
const minor = typeof source['minor'] === 'string' ? Number(source['minor']) : source['minor'];
return Number.isFinite(minor) ? { ...source, minor } : raw;
// Through `numeric` for the same reason `scale` is: `Number('')` is 0, so a blank amount
// field converted here would reach the validator as a legitimate zero and book an empty
// price input as free. A blank stays a blank and fails validation, which is the real error.
const rawMinor = source['minor'];
const minor = typeof rawMinor === 'string' ? numeric(rawMinor) : rawMinor;
if (typeof minor !== 'number' || !Number.isFinite(minor)) return raw;
// `scale` arrives as text from a query string exactly as `minor` does. Left a string it
// would fail validation on a value whose `minor` the same request just had converted.
const scale = numeric(source['scale']);
return { ...source, minor, ...(scale === undefined ? {} : { scale }) };
}

@@ -72,0 +106,0 @@ case 'union': {

@@ -23,2 +23,8 @@ // Single responsibility: this package's error codes. `@ultimat3/schema` is tier 0 and may not

X_SCHEMA_UNSUPPORTED: { title: 'the active schema provider cannot do this' },
X_SCHEMA_DISCRIMINANT_INVALID: {
title: 'a discriminated union member can never be dispatched to',
},
X_SCHEMA_DEFAULT_UNSHAREABLE: {
title: 'a schema default cannot be copied per parse',
},
});

@@ -131,2 +137,16 @@

/**
* Thrown where the union is BUILT, not where a value is parsed: a member no tag can route to is
* wrong for every input, so the first import of the authoring file is the earliest honest place
* to say so — never a request that quietly took the wrong branch.
*/
export class DiscriminantInvalidError extends SchemaError {
static readonly code = 'X_SCHEMA_DISCRIMINANT_INVALID';
override readonly name = 'DiscriminantInvalidError';
constructor(init: Omit<SchemaErrorInit, 'code'>) {
super({ ...init, code: DiscriminantInvalidError.code });
}
}
export class SchemaUnsupportedError extends SchemaError {

@@ -133,0 +153,0 @@ static readonly code = 'X_SCHEMA_UNSUPPORTED';

@@ -10,2 +10,3 @@ // Single responsibility: the public API of @ultimat3/schema. Explicit named exports only.

Path,
Refinement,
Schema,

@@ -17,14 +18,7 @@ Shape,

} from './builder';
export {
checkOf,
describeValue,
expected,
fail,
failWith,
makeSchema,
pass,
VENDOR,
} from './builder';
export { checkOf, fail, failWith, makeSchema, pass, VENDOR } from './builder';
export type { QuerySource } from './coerce';
export { coerceInput, coerceNode, coerceQuery } from './coerce';
export { describeValue, expected } from './describe-value';
export { discriminatedUnionSchema } from './discriminated-union';
export type {

@@ -37,2 +31,3 @@ SchemaErrorCodeDeclaration,

export {
DiscriminantInvalidError,
isSchemaError,

@@ -48,2 +43,3 @@ SCHEMA_ERROR_CODES,

JsonSchemaDialect,
JsonSchemaDiscriminator,
JsonSchemaType,

@@ -53,3 +49,10 @@ ToJsonSchemaOptions,

export { nodeToJsonSchema, toJsonSchema, toMcpInputSchema } from './json-schema';
export type { SchemaFormat, SchemaKind, SchemaNode } from './node';
export type { MoneyValue } from './money-value';
export {
CURRENCY_CODE_PATTERN,
isCurrencyCode,
isMoneyScale,
MAX_MONEY_SCALE,
} from './money-value';
export type { SchemaFormat, SchemaKind, SchemaNode, SchemaRefinement } from './node';
export { isSchemaNode, nodeOf, requiredKeys } from './node';

@@ -91,3 +94,2 @@ export type { SchemaProvider } from './provider';

export type {
MoneyValue,
NumberSchema,

@@ -107,3 +109,4 @@ ObjectSchema,

recordSchema,
refineSchema,
unionSchema,
} from './validators';

@@ -5,7 +5,21 @@ // Single responsibility: SchemaNode -> JSON Schema. Load-bearing: OpenAPI request/response

import { requiredKeys, type SchemaNode } from './node';
import { CURRENCY_CODE_PATTERN, MAX_MONEY_SCALE } from './money-value';
import { requiredKeys, type SchemaNode, type SchemaRefinement } from './node';
import { introspect } from './provider';
export type JsonSchemaType = 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array';
export type JsonSchemaType =
| 'string'
| 'number'
| 'integer'
| 'boolean'
| 'object'
| 'array'
/** Only ever emitted as the second branch of a nullable field's `anyOf` — see `annotate`. */
| 'null';
/** OpenAPI 3.1's tagged-union hint. `propertyName` alone: the branches here are inline, not `$ref`. */
export interface JsonSchemaDiscriminator {
readonly propertyName: string;
}
export interface JsonSchema {

@@ -29,3 +43,10 @@ readonly $schema?: string;

readonly anyOf?: readonly JsonSchema[];
readonly discriminator?: JsonSchemaDiscriminator;
readonly title?: string;
/**
* The refinements a consumer can act on mechanically. The prose copy also lands in
* `description`, because that is the only field an LLM reading an MCP tool schema is
* guaranteed to see — the extension is for code generators, the description is for readers.
*/
readonly 'x-ultimate-refinements'?: readonly SchemaRefinement[];
}

@@ -69,9 +90,45 @@

/**
* JSON Schema's `pattern` is an ECMA-262 source with no flag syntax, so a flagged pattern is
* stated in prose instead of silently narrowed: a consumer applying `pattern` alone would refuse
* values this schema accepts, and there is nowhere honest to hide that.
*/
function patternNote(node: SchemaNode): string | undefined {
if (node.pattern === undefined) return undefined;
const flags = node.patternFlags;
return flags === undefined || flags === ''
? undefined
: `pattern is applied with RegExp flags "${flags}"`;
}
function convert(node: SchemaNode): JsonSchema {
const annotate = (schema: JsonSchema): JsonSchema => ({
...schema,
...(node.description === undefined ? {} : { description: node.description }),
const refinements = node.refinements ?? [];
const notes = [
node.description,
patternNote(node),
...refinements.map((refinement) => refinement.message),
].filter((part): part is string => part !== undefined);
const described = notes.length === 0 ? undefined : notes.join(' — ');
const annotations: JsonSchema = {
...(described === undefined ? {} : { description: described }),
...(node.hasDefault === true ? { default: node.default } : {}),
});
...(refinements.length === 0 ? {} : { 'x-ultimate-refinements': refinements }),
};
/**
* `null` is a VALUE the field holds, so it joins the type union rather than the annotations —
* `{ anyOf: [<converted>, { type: 'null' }] }`, which is how OpenAPI 3.1 / JSON Schema 2020-12
* spell it (3.0's `nullable: true` keyword is gone). Dropping it published a contract every
* surface at once disagreed with: OpenAPI bodies, MCP `inputSchema`, `respondToolFor` and the
* typed client all forbade a `null` the action's own `output:` validator returns.
*
* The annotations stay OUTSIDE the `anyOf` — they describe the field, not one branch of it —
* and `requiredKeys` is untouched, because nullable is not optional: the key is still sent.
*/
const annotate = (schema: JsonSchema): JsonSchema =>
node.nullable === true
? { anyOf: [schema, { type: 'null' }], ...annotations }
: { ...schema, ...annotations };
switch (node.kind) {

@@ -100,3 +157,10 @@ case 'string':

case 'union':
return annotate({ anyOf: (node.anyOf ?? []).map(convert) });
return annotate({
anyOf: (node.anyOf ?? []).map(convert),
// Only when the union actually dispatches on a key: an untagged `t.union` carrying a
// `discriminator` would tell a generator to read a property no member declares.
...(node.discriminant === undefined
? {}
: { discriminator: { propertyName: node.discriminant } }),
});
case 'record':

@@ -111,4 +175,22 @@ return annotate({

properties: {
minor: { type: 'integer', description: 'amount in minor units, never a float' },
currency: { type: 'string', pattern: '^[A-Z]{3}$' },
minor: {
type: 'integer',
description: 'amount in minor units, never a float',
// The safe-integer range the validator enforces, so a generated client refuses the
// same value the boundary does instead of learning about it from a 500.
minimum: -Number.MAX_SAFE_INTEGER,
maximum: Number.MAX_SAFE_INTEGER,
},
// The pattern the validator applies, not a copy of it: this object IS the contract a
// generated client checks against, so a widened predicate here would be a client
// refusing a code the boundary accepts.
currency: { type: 'string', pattern: CURRENCY_CODE_PATTERN },
// Optional, never required: `additionalProperties: false` alone would make a generated
// client refuse a scaled amount the framework's own validator accepts.
scale: {
type: 'integer',
description: 'decimal places `minor` counts; absent means the currency’s own',
minimum: 0,
maximum: MAX_MONEY_SCALE,
},
},

@@ -115,0 +197,0 @@ required: ['minor', 'currency'],

@@ -30,2 +30,13 @@ // Single responsibility: the introspectable schema IR. Flat on purpose — OpenAPI generation,

/**
* A refinement as the IR carries it: the predicate's *declaration*, never the predicate. A closure
* cannot cross into OpenAPI or an MCP tool schema, so what ships is the name and the rule text —
* enough for a generated client to state the constraint and for a form to label the failure.
*/
export interface SchemaRefinement {
readonly name: string;
readonly message: string;
readonly path?: readonly string[] | undefined;
}
export interface SchemaNode {

@@ -44,2 +55,8 @@ readonly kind: SchemaKind;

readonly pattern?: string | undefined;
/**
* The RegExp's flags, carried beside the source for the same reason. Dropping them made
* `t.string.pattern(/^[a-z]+$/i)` reject `ABC` while quoting the pattern that matches it.
* JSON Schema's `pattern` has no flags, so `json-schema.ts` states them in `description`.
*/
readonly patternFlags?: string | undefined;
readonly minimum?: number | undefined;

@@ -53,3 +70,15 @@ readonly maximum?: number | undefined;

readonly anyOf?: readonly SchemaNode[] | undefined;
/**
* The key a `union` dispatches on. Additive rather than a `'discriminatedUnion'` kind: every
* consumer that switches on `kind` — `json-schema.ts`, `coerce.ts`, `action`'s sample generator,
* the admin form generator — already handles `'union'` correctly, and a new kind would have
* fallen through each of their `default:` branches to an empty schema without failing anything.
*/
readonly discriminant?: string | undefined;
readonly valueNode?: SchemaNode | undefined;
/**
* Rules the structural fields cannot state. Carried beside `kind` rather than wrapping it for
* the reason `discriminant` is: a refined string must still read as a string everywhere.
*/
readonly refinements?: readonly SchemaRefinement[] | undefined;
}

@@ -56,0 +85,0 @@

@@ -5,12 +5,7 @@ // Single responsibility: the blessed `t` namespace. One import, one way to declare a schema.

import type { AnySchema, Schema, Shape } from './builder';
import type { AnySchema, Refinement, Schema, Shape } from './builder';
import type { MoneyValue } from './money-value';
import { schemaProvider } from './provider';
import type { InferInput, InferOutput, StandardSchemaV1 } from './standard';
import type {
MoneyValue,
NumberSchema,
ObjectSchema,
StringSchema,
TNamespace,
} from './validators';
import type { NumberSchema, ObjectSchema, StringSchema, TNamespace } from './validators';

@@ -85,2 +80,24 @@ function provider(): TNamespace {

},
/**
* `t.discriminatedUnion('kind', postBody, pageBody)` — the blessed spelling for a tagged shape.
* A failure reports the branch `kind` named, so the caller reads one field error instead of
* every branch's reasons concatenated.
*/
discriminatedUnion<S extends readonly [AnySchema, ...AnySchema[]]>(
discriminant: string,
...members: S
): Schema<InferInput<S[number]>, InferOutput<S[number]>> {
return provider().discriminatedUnion(discriminant, ...members);
},
/**
* `t.refine(range, { name: 'end-after-start', message: 'endDate must be after startDate', … })`
* — a cross-field rule declared ON the schema, so OpenAPI, the MCP tool schema and the form
* binding all state it instead of it living in a handler where no projection can see it.
*
* Delegates to the method for the reason `t.nullable` does: `refine` composes an existing
* schema rather than building one, so a swapped provider's schemas keep their own behaviour.
*/
refine<In, Out>(schema: Schema<In, Out>, refinement: Refinement<Out>): Schema<In, Out> {
return schema.refine(refinement);
},
record<S extends AnySchema>(

@@ -87,0 +104,0 @@ values: S,

@@ -9,7 +9,8 @@ // Single responsibility: the builtin, dependency-free validators behind `t`. Small on purpose —

checkOf,
expected,
fail,
failWith,
isPlainObject,
makeSchema,
pass,
type Refinement,
type Schema,

@@ -20,11 +21,8 @@ type Shape,

} from './builder';
import { expected } from './describe-value';
import { discriminatedUnionSchema } from './discriminated-union';
import { type MoneyValue, moneySchema } from './money-value';
import type { SchemaNode } from './node';
import type { InferInput, InferOutput, StandardIssue } from './standard';
/** Structurally identical to `Money` in `@ultimat3/money`. Never a float. */
export interface MoneyValue {
readonly minor: number;
readonly currency: string;
}
const EMAIL_RE = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/;

@@ -34,3 +32,2 @@ const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;

const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const CURRENCY_RE = /^[A-Z]{3}$/;

@@ -58,13 +55,56 @@ export interface StringSchema extends Schema<string, string> {

/**
* The literal the caller wrote, flags included. An error quoting `/^[a-z]+$/` for a pattern
* carrying `i` names something that would have matched the value it just refused.
*/
function describePattern(node: SchemaNode): string {
return node.patternFlags === undefined || node.patternFlags === ''
? String(node.pattern)
: `/${node.pattern}/${node.patternFlags}`;
}
/**
* Characters, not UTF-16 code units — the unit `json-schema.ts` already promises, since JSON
* Schema defines `minLength`/`maxLength` over code points and the message here has always said
* "chars". `'👍'.length` is 2, so `t.string.max(1)` refused a value the published schema, a human
* and Postgres' `char_length` all count as one.
*
* Only a surrogate makes the two counts differ, so the string is walked only when one is present
* — every ASCII value keeps the O(1) read this replaced.
*/
const HAS_SURROGATE = /[\uD800-\uDBFF]/;
function charCount(value: string): number {
return HAS_SURROGATE.test(value) ? [...value].length : value.length;
}
/**
* Compiled once per schema, not once per validation. `lastIndex` is reset because a `g` or `y`
* pattern carries it between calls — a cached global RegExp answers `false` for the second
* `.test()` of the very value it just accepted, which the per-call construction hid.
*/
function patternTester(node: SchemaNode): ((value: string) => boolean) | undefined {
if (node.pattern === undefined) return undefined;
const source = node.pattern;
const flags = node.patternFlags;
let compiled: RegExp | undefined;
return (value) => {
compiled ??= new RegExp(source, flags);
compiled.lastIndex = 0;
return compiled.test(value);
};
}
function stringLike(node: SchemaNode, what: string, test?: (value: string) => boolean) {
const matchesPattern = patternTester(node);
const check: Check<string> = (value, path) => {
if (typeof value !== 'string') return fail(path, expected(what, value));
if (node.minLength !== undefined && value.length < node.minLength) {
if (node.minLength !== undefined && charCount(value) < node.minLength) {
return fail(path, expected(`${what} of at least ${node.minLength} chars`, value));
}
if (node.maxLength !== undefined && value.length > node.maxLength) {
if (node.maxLength !== undefined && charCount(value) > node.maxLength) {
return fail(path, expected(`${what} of at most ${node.maxLength} chars`, value));
}
if (node.pattern !== undefined && !new RegExp(node.pattern).test(value)) {
return fail(path, expected(`${what} matching ${node.pattern}`, value));
if (matchesPattern !== undefined && !matchesPattern(value)) {
return fail(path, expected(`${what} matching ${describePattern(node)}`, value));
}

@@ -87,3 +127,14 @@ if (test !== undefined && !test(value)) return fail(path, expected(what, value));

max: (length) => makeStringSchema({ ...node, maxLength: length }, what, test),
pattern: (regex) => makeStringSchema({ ...node, pattern: regex.source }, what, test),
pattern: (regex) =>
makeStringSchema(
// Flags travel with the source: a node holding only `source` rebuilt a *different*
// RegExp, so `/^[a-z]+$/i` refused `ABC` and quoted the pattern that matches it.
{
...node,
pattern: regex.source,
...(regex.flags === '' ? {} : { patternFlags: regex.flags }),
},
what,
test,
),
};

@@ -117,6 +168,2 @@ }

function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export function objectSchema<S extends Shape>(shape: S): ObjectSchema<S> {

@@ -223,2 +270,11 @@ const properties: Record<string, SchemaNode> = {};

/**
* Keys that reach an object's prototype rather than its own properties. A record's keys are the
* caller's, so `{"__proto__":{…}}` on a `{}` literal set the OUTPUT's prototype: `Object.keys`
* answered `[]` while `settings[k] ?? fallback` handed a handler the attacker's value for a key
* that was never sent. Refused by name AND built on a null prototype — the null prototype alone
* would keep `__proto__` as a silent own key nobody declared.
*/
const PROTOTYPE_KEYS: ReadonlySet<string> = new Set(['__proto__', 'constructor', 'prototype']);
export function recordSchema<S extends AnySchema>(

@@ -234,4 +290,11 @@ values: S,

const issues: StandardIssue[] = [];
const out: Record<string, unknown> = {};
const out: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
for (const [key, entry] of Object.entries(value)) {
if (PROTOTYPE_KEYS.has(key)) {
issues.push({
message: expected(`a record key that is not ${[...PROTOTYPE_KEYS].join(' | ')}`, key),
path: [...path, key],
});
continue;
}
const result = valueCheck(entry, [...path, key]);

@@ -259,2 +322,14 @@ if (result.ok) out[key] = result.value;

/**
* The free-function spelling of `schema.refine(...)`, shipped beside `t.refine` for the same
* reason `nullableSchema` ships beside `t.nullable`: a call site that already holds a schema
* should not have to reach for the namespace.
*/
export function refineSchema<In, Out>(
schema: Schema<In, Out>,
refinement: Refinement<Out>,
): Schema<In, Out> {
return schema.refine(refinement);
}
function isTimeZone(value: string): boolean {

@@ -295,33 +370,2 @@ try {

const moneySchema: Schema<MoneyValue, MoneyValue> = makeSchema<MoneyValue, MoneyValue>(
{
kind: 'money',
description: 'integer minor units plus an ISO 4217 currency code',
properties: {
minor: { kind: 'number', integer: true },
currency: { kind: 'string', pattern: CURRENCY_RE.source },
},
},
(value, path) => {
if (!isPlainObject(value)) return fail(path, expected('a Money object', value));
const minor = value['minor'];
const currency = value['currency'];
const issues: StandardIssue[] = [];
if (typeof minor !== 'number' || !Number.isInteger(minor)) {
issues.push({
message: expected('an integer number of minor units', minor),
path: [...path, 'minor'],
});
}
if (typeof currency !== 'string' || !CURRENCY_RE.test(currency)) {
issues.push({
message: expected('a 3-letter ISO 4217 code', currency),
path: [...path, 'currency'],
});
}
if (issues.length > 0) return failWith(issues);
return pass({ minor: minor as number, currency: currency as string });
},
);
/** The shape a schema provider must implement to back `t`. */

@@ -352,2 +396,9 @@ export interface TNamespace {

): Schema<InferInput<S[number]>, InferOutput<S[number]>>;
/** A union routed by one literal key: one branch's issues on failure, not every branch's. */
discriminatedUnion<S extends readonly [AnySchema, ...AnySchema[]]>(
discriminant: string,
...members: S
): Schema<InferInput<S[number]>, InferOutput<S[number]>>;
/** A rule the IR cannot state structurally — cross-field, cross-row, or arithmetic. */
refine<In, Out>(schema: Schema<In, Out>, refinement: Refinement<Out>): Schema<In, Out>;
record<S extends AnySchema>(

@@ -397,2 +448,4 @@ values: S,

union: unionSchema,
discriminatedUnion: discriminatedUnionSchema,
refine: refineSchema,
record: recordSchema,

@@ -399,0 +452,0 @@ nullable: nullableSchema,