// Single responsibility: the builtin, dependency-free validators behind `t`. Small on purpose — // ArkType or Zod replace them wholesale via `configureSchemaProvider()` — neither ships; the IR and the // Standard Schema surface are what the rest of the framework actually depends on. import { isAbsoluteUrl } from './absolute-url'; import { type ArrayBounds, arraySchema } from './array-schema'; import { type AnySchema, type Check, checkOf, fail, failWith, isPlainObject, makeSchema, pass, type Refinement, type Schema, type Shape, type ShapeInput, type ShapeOutput, } from './builder'; import { charCount } from './char-count'; import { expected } from './describe-value'; import { discriminatedUnionSchema } from './discriminated-union'; import { isIsoDateTime, isZonelessDateTime } from './iso-date'; import { type JsonValue, jsonSchema } from './json-value'; import { type MoneyValue, moneySchema } from './money-value'; import type { SchemaNode } from './node'; import { isPrototypeKey, PROTOTYPE_KEYS } from './prototype-keys'; import type { InferInput, InferOutput, StandardIssue } from './standard'; import { isIanaZoneName } from './time-zone-name'; import { unstorable } from './unstorable-text'; const EMAIL_RE = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/; const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const CURSOR_RE = /^[A-Za-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; export interface StringSchema extends Schema { min(length: number): StringSchema; max(length: number): StringSchema; pattern(regex: RegExp): StringSchema; } export interface NumberSchema extends Schema { min(value: number): NumberSchema; max(value: number): NumberSchema; int(): NumberSchema; } export interface ObjectSchema extends Schema, ShapeOutput> { readonly shape: S; extend(extra: E): ObjectSchema>; pick(...keys: readonly K[]): ObjectSchema>; omit(...keys: readonly K[]): ObjectSchema>; } type Simplified = { [K in keyof S]: S[K] } & {}; /** * 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}`; } /** * 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 = (value, path) => { if (typeof value !== 'string') return fail(path, expected(what, value)); // A NUL or a lone surrogate passed here reached the row write — or a `where` bind — as // `X_DB_STATEMENT_FAILED`, a 500 blaming the server for what the caller sent (`unstorable`). // Only those: a tab, a newline or any other C0 control is legal text Postgres stores unchanged. const unfit = unstorable(value); if (unfit !== undefined) return fail(path, `${expected(what, value)} ${unfit}`); if (node.minLength !== undefined && charCount(value) < node.minLength) { return fail(path, expected(`${what} of at least ${node.minLength} chars`, value)); } if (node.maxLength !== undefined && charCount(value) > node.maxLength) { return fail(path, expected(`${what} of at most ${node.maxLength} chars`, value)); } if (matchesPattern !== undefined && !matchesPattern(value)) { return fail(path, expected(`${what} matching ${describePattern(node)}`, value)); } if (test !== undefined && !test(value)) return fail(path, expected(what, value)); return pass(value); }; return check; } function makeStringSchema( node: SchemaNode, what: string, test?: (value: string) => boolean, ): StringSchema { const base = makeSchema(node, stringLike(node, what, test)); return { ...base, min: (length) => makeStringSchema({ ...node, minLength: length }, what, test), max: (length) => makeStringSchema({ ...node, maxLength: length }, 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, ), }; } function makeNumberSchema(node: SchemaNode): NumberSchema { const check: Check = (value, path) => { if (typeof value !== 'number' || !Number.isFinite(value)) { return fail(path, expected('a finite number', value)); } // Safe, not merely whole — the same rule `money-value.ts` states one file over, for the same // reason: `Number.isInteger` let 2^53 through the boundary as a 200, so the policy gate and // the handler ran and the ROW WRITE refused it as a 500 (`entity`'s `columns.ts` demands // `Number.isSafeInteger`). The same value refused twice, once with a field path and once // without. Above 2^53 a double cannot name its own successor, so a "whole" number there is // already a rounded one. if (node.integer === true && !Number.isSafeInteger(value)) { return fail(path, expected('a safe integer', value)); } if (node.minimum !== undefined && value < node.minimum) { return fail(path, expected(`a number >= ${node.minimum}`, value)); } if (node.maximum !== undefined && value > node.maximum) { return fail(path, expected(`a number <= ${node.maximum}`, value)); } return pass(value); }; const base = makeSchema(node, check); return { ...base, min: (value) => makeNumberSchema({ ...node, minimum: value }), max: (value) => makeNumberSchema({ ...node, maximum: value }), int: () => makeNumberSchema({ ...node, integer: true }), }; } export function objectSchema(shape: S): ObjectSchema { // `Object.create(null)` for the same reason the parse output below uses one, on the other side // of the same rule: a DECLARED `__proto__` field assigned into a `{}` literal hits the prototype // setter and never becomes a key, so the field vanished from the IR `json-schema.ts` publishes // and `coerce.ts` walks while the `checks` list beside it went on validating it. const properties: Record = Object.create(null) as Record; const checks: [string, Check][] = []; for (const [key, member] of Object.entries(shape)) { properties[key] = member.node; checks.push([key, checkOf(member)]); } const node: SchemaNode = { kind: 'object', properties }; const check: Check> = (value, path) => { if (!isPlainObject(value)) return fail(path, expected('an object', value)); const issues: StandardIssue[] = []; // `Object.create(null)`, for `recordSchema`'s reason one screen down: on a `{}` literal // `out['__proto__'] = value` hits the `Object.prototype` SETTER, so a DECLARED `__proto__` // field parsed to `{}` whose prototype was the caller's object — every key of it then read // back off a value nobody sent. const out: Record = Object.create(null) as Record; for (const [key, memberCheck] of checks) { // `Object.hasOwn`, never a raw index — the guard `coerce.ts` already applies to the same // read. `value['toString']` answered the INHERITED function for an input that carried no // such key, so a field named after a prototype member was unsatisfiable for every input, // its `.default()` could never fire, and the HTTP path and this one disagreed about what // the caller sent. const raw = Object.hasOwn(value, key) ? value[key] : undefined; const result = memberCheck(raw, [...path, key]); if (result.ok) { // Unknown keys are dropped, never forwarded — no mass assignment through an action. if (result.value !== undefined) out[key] = result.value; } else { issues.push(...result.issues); } } if (issues.length > 0) return failWith(issues); return pass(out as ShapeOutput); }; const base = makeSchema, ShapeOutput>(node, check); return { ...base, shape, extend: (extra) => objectSchema({ ...shape, ...extra } as Simplified), // `Object.create(null)` in both, for the reason the IR above states and with a worse outcome // here: `next['__proto__'] = member` on a `{}` literal hits the prototype SETTER, so the field // is not mis-published, it is GONE — `pick('__proto__')` answered a schema with no properties // at all, dropping the one field the caller asked to keep from validation, from the IR and // from `parse()`'s output. `extend` needs none of this: object spread defines own properties // and never invokes a setter. pick: (...keys) => { const next: Record = Object.create(null) as Record; for (const key of keys) next[key] = shape[key] as AnySchema; return objectSchema(next) as ObjectSchema>; }, omit: (...keys) => { const drop = new Set(keys); const next: Record = Object.create(null) as Record; for (const [key, member] of Object.entries(shape)) { if (!drop.has(key)) next[key] = member; } return objectSchema(next) as ObjectSchema>; }, }; } export function enumSchema( values: V, ): Schema { const node: SchemaNode = { kind: 'enum', values: [...values] }; return makeSchema(node, (value, path) => { if (typeof value === 'string' && (values as readonly string[]).includes(value)) { return pass(value as V[number]); } return fail(path, expected(`one of ${values.join(' | ')}`, value)); }); } export function literalSchema(value: V): Schema { const node: SchemaNode = { kind: 'literal', literal: value }; return makeSchema(node, (candidate, path) => candidate === value ? pass(value) : fail(path, expected(JSON.stringify(value), candidate)), ); } export function unionSchema( ...members: S ): Schema, InferOutput> { const checks = members.map((member) => checkOf(member)); const node: SchemaNode = { kind: 'union', anyOf: members.map((member) => member.node) }; return makeSchema, InferOutput>(node, (value, path) => { const reasons: string[] = []; for (const memberCheck of checks) { const result = memberCheck(value, path); if (result.ok) return pass(result.value as InferOutput); reasons.push(result.issues.map((issue) => issue.message).join(', ')); } return fail(path, `no union member matched (${reasons.join(' | ')})`); }); } export function recordSchema( values: S, ): Schema>>, Record>> { const valueCheck = checkOf(values); const node: SchemaNode = { kind: 'record', valueNode: values.node }; return makeSchema>>, Record>>( node, (value, path) => { if (!isPlainObject(value)) return fail(path, expected('an object', value)); const issues: StandardIssue[] = []; const out: Record = Object.create(null) as Record; for (const [index, [key, entry]] of Object.entries(value).entries()) { // The entry's POSITION, never its key. `describe-value.ts`'s absolute is stated about the // rejected value, and a record's KEY is the caller's data just as much: the path travels // `formatIssue` -> `bodyInvalid`'s `cause` -> the problem document AND the log line, and // core's logger redacts by key, so a key baked into a string has no key left to redact. // `@ultimat3/http`'s `bodyInvalid` states the contract this was breaking in its own doc // block — *"`issues` … must name only facts the framework itself chose"*. A record keyed // by an email address, a phone number or a pasted credential wrote every one of them into // the central log index, in the same shape the password bug did. // // The index rather than nothing at all: the segment's whole job is to say WHICH entry // failed, and dropping it makes three failing entries render three identical lines. It is // the same segment `arraySchema` already uses, so `formatPath` renders `meta[3]` with no // new spelling to learn. It is `Object.entries` order — integer-like keys sort ahead of // string ones — so it names an entry that exists rather than a byte offset in the body. const at = [...path, index]; // A record is stored as `jsonb` far more often than not, and jsonb refuses an unstorable // KEY exactly as it does a value — so the key is refused here, by position, not quoted. const unfitKey = unstorable(key); if (unfitKey !== undefined) { issues.push({ message: `expected a record key, received one ${unfitKey}`, path: at }); continue; } if (isPrototypeKey(key)) { issues.push({ // The refused NAMES are a closed set declared right here, so the message may state // them: that is the one part of this line the caller did not choose. message: expected(`a record key that is not ${PROTOTYPE_KEYS.join(' | ')}`, key), path: at, }); continue; } const result = valueCheck(entry, at); if (result.ok) out[key] = result.value; else issues.push(...result.issues); } if (issues.length > 0) return failWith(issues); return pass(out as Record>); }, ); } export function nullableSchema( schema: S, ): Schema | null, InferOutput | null> { return schema.nullable() as Schema | null, InferOutput | null>; } export function optionalSchema( schema: S, ): Schema | undefined, InferOutput | undefined> { return schema.optional() as Schema | undefined, InferOutput | undefined>; } /** * 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( schema: Schema, refinement: Refinement, ): Schema { return schema.refine(refinement); } function isLocale(value: string): boolean { try { return Intl.getCanonicalLocales(value).length === 1; } catch { return false; } } const dateSchema: Schema = makeSchema( { kind: 'date', format: 'date-time' }, (value, path) => { if (value instanceof Date) { return Number.isNaN(value.getTime()) ? fail(path, expected('a valid Date', value)) : pass(value); } if (typeof value === 'string' || typeof value === 'number') { // Enforced, not documented: the node publishes `format: 'date-time'` and this refusal has // always said ISO-8601, while `new Date` resolved a zone-less clock time through the host // process's zone — reachable from the wire, since `coerceQuery` routes a `t.date` field // here and a container's `TZ` then decided which instant a query parameter meant. if (typeof value === 'string' && isZonelessDateTime(value)) { return fail(path, expected('an ISO-8601 date-time with an offset or Z', value)); } // `new Date` also parses `'March 14, 2026'` and `'12'` at the host's local midnight, so the // shape is checked first: a string that is not ISO-8601 is refused, not guessed at. if (typeof value === 'string' && !isIsoDateTime(value)) { return fail(path, expected('an ISO-8601 date-time', value)); } const parsed = new Date(value); return Number.isNaN(parsed.getTime()) ? fail(path, expected('an ISO-8601 date-time', value)) : pass(parsed); } return fail(path, expected('an ISO-8601 date-time', value)); }, ); /** The shape a schema provider must implement to back `t`. */ export interface TNamespace { readonly string: StringSchema; readonly number: NumberSchema; readonly boolean: Schema; readonly uuid: StringSchema; readonly email: StringSchema; readonly url: StringSchema; readonly date: Schema; readonly money: Schema; readonly timezone: StringSchema; readonly locale: StringSchema; readonly slug: StringSchema; readonly cursor: StringSchema; /** Any JSON value, bounded in depth — a payload whose shape is somebody else's. */ json(): Schema; object(shape: S): ObjectSchema; /** `t.array(items, { min, max })` — inclusive item-count bounds, both optional. */ array( items: S, bounds?: ArrayBounds, ): Schema[], InferOutput[]>; enum(values: V): Schema; /** Variadic `enum`; the blessed spelling at call sites. */ enumerated( ...values: V ): Schema; literal(value: V): Schema; union( ...members: S ): Schema, InferOutput>; /** A union routed by one literal key: one branch's issues on failure, not every branch's. */ discriminatedUnion( discriminant: string, ...members: S ): Schema, InferOutput>; /** A rule the IR cannot state structurally — cross-field, cross-row, or arithmetic. */ refine(schema: Schema, refinement: Refinement): Schema; record( values: S, ): Schema>>, Record>>; /** Null is a value the column holds; `optional` is the caller omitting the field. */ nullable(schema: S): Schema | null, InferOutput | null>; optional( schema: S, ): Schema | undefined, InferOutput | undefined>; } export const builtinT: TNamespace = Object.freeze({ string: makeStringSchema({ kind: 'string', minLength: 1 }, 'a non-empty string'), number: makeNumberSchema({ kind: 'number' }), boolean: makeSchema({ kind: 'boolean' }, (value, path) => typeof value === 'boolean' ? pass(value) : fail(path, expected('a boolean', value)), ), uuid: makeStringSchema({ kind: 'string', format: 'uuid' }, 'a uuid', (value) => UUID_RE.test(value), ), email: makeStringSchema({ kind: 'string', format: 'email' }, 'an email address', (value) => EMAIL_RE.test(value), ), url: makeStringSchema({ kind: 'string', format: 'uri' }, 'an absolute URL', isAbsoluteUrl), date: dateSchema, money: moneySchema, timezone: makeStringSchema( { kind: 'string', format: 'timezone' }, 'an IANA time zone', isIanaZoneName, ), locale: makeStringSchema({ kind: 'string', format: 'locale' }, 'a BCP-47 locale', isLocale), slug: makeStringSchema({ kind: 'string', format: 'slug', pattern: SLUG_RE.source }, 'a slug'), cursor: makeStringSchema( { kind: 'string', format: 'cursor', pattern: CURSOR_RE.source }, 'an opaque base64url cursor', ), json: jsonSchema, object: objectSchema, array: arraySchema, enum: enumSchema, enumerated: (...values: V) => enumSchema(values), literal: literalSchema, union: unionSchema, discriminatedUnion: discriminatedUnionSchema, refine: refineSchema, record: recordSchema, nullable: nullableSchema, optional: optionalSchema, });