@ultimat3/schema 1.2.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,117 @@
1
+ // Single responsibility: a union that dispatches on one literal key instead of trying every
2
+ // member. Its own file because the dispatch table and its two authoring refusals are the whole of
3
+ // it, and `validators.ts` is already at the size where one more composite stops being readable.
4
+
5
+ import {
6
+ type AnySchema,
7
+ type Check,
8
+ checkOf,
9
+ fail,
10
+ isPlainObject,
11
+ makeSchema,
12
+ type Schema,
13
+ } from './builder';
14
+ import { expected } from './describe-value';
15
+ import { DiscriminantInvalidError } from './errors';
16
+ import type { SchemaNode } from './node';
17
+ import type { InferInput, InferOutput } from './standard';
18
+
19
+ /**
20
+ * What a tag can be — exactly what `SchemaNode.literal` and `SchemaNode.values` can hold. Spelled
21
+ * out rather than left `unknown` because these reach a `cause:` through `String()`, and a value an
22
+ * app controls reaching a refusal's own text is how three constructors learned to throw instead
23
+ * of refusing (`bun run error-render`).
24
+ */
25
+ type SchemaTag = string | number | boolean | null | undefined;
26
+
27
+ /** The literal(s) a member declares at the discriminant, or `undefined` if it declares none. */
28
+ function declaredValues(node: SchemaNode, discriminant: string): readonly SchemaTag[] | undefined {
29
+ const child = node.properties?.[discriminant];
30
+ if (child === undefined) return undefined;
31
+ // `kind` gates rather than `literal !== undefined`: `t.literal(false)` is a legal discriminant
32
+ // and a truthiness test would have called it undeclared.
33
+ if (child.kind === 'literal') return [child.literal];
34
+ // An enum member is a legal discriminant too — one branch owning several codes is a real shape
35
+ // (`status: t.enum(['queued', 'running'])`), and refusing it would force a duplicated branch.
36
+ if (child.kind === 'enum' && child.values !== undefined && child.values.length > 0) {
37
+ return [...child.values];
38
+ }
39
+ return undefined;
40
+ }
41
+
42
+ function refuseUndeclared(discriminant: string, index: number, kind: string): never {
43
+ throw new DiscriminantInvalidError({
44
+ cause: `member #${index} of discriminatedUnion("${discriminant}") — IR kind "${kind}" — declares no literal at "${discriminant}"`,
45
+ fix: `give member #${index} a literal discriminant, t.object({ ${discriminant}: t.literal('…'), … }), or use t.union(...) if the members share no key`,
46
+ meta: { discriminant, index, kind },
47
+ });
48
+ }
49
+
50
+ function refuseDuplicate(discriminant: string, index: number, tag: SchemaTag): never {
51
+ throw new DiscriminantInvalidError({
52
+ cause: `member #${index} of discriminatedUnion("${discriminant}") claims the tag ${String(tag)}, which an earlier member already claims`,
53
+ fix: `give member #${index} its own value at "${discriminant}", or merge the two members into one`,
54
+ meta: { discriminant, index, tag: String(tag) },
55
+ });
56
+ }
57
+
58
+ /** `post | page | 2 | false` — every accepted tag, in declaration order. Developer data only. */
59
+ function renderKnown(known: readonly SchemaTag[]): string {
60
+ return known.map((value) => String(value)).join(' | ');
61
+ }
62
+
63
+ /**
64
+ * A union whose branch is chosen by one key, not by trying every member in turn.
65
+ *
66
+ * Two things `t.union(...)` cannot do. The message: a failure reports the branch the discriminant
67
+ * NAMED — `t.union` reports every branch's reasons at once, so a typo in a `post` body arrived as
68
+ * N contradictory complaints and the field that was actually wrong was named in none of them. The
69
+ * IR: `discriminator.propertyName` reaches OpenAPI and the MCP tool schema, so a code generator
70
+ * emits one tagged type instead of an untagged `anyOf`.
71
+ *
72
+ * The node stays `kind: 'union'` with a `discriminant` beside it — see `node.ts` for why a new
73
+ * `SchemaKind` would have degraded silently in every consumer that already handles unions.
74
+ */
75
+ export function discriminatedUnionSchema<S extends readonly [AnySchema, ...AnySchema[]]>(
76
+ discriminant: string,
77
+ ...members: S
78
+ ): Schema<InferInput<S[number]>, InferOutput<S[number]>> {
79
+ type Out = InferOutput<S[number]>;
80
+ const branches = new Map<unknown, Check<Out>>();
81
+ const known: SchemaTag[] = [];
82
+
83
+ for (const [index, member] of members.entries()) {
84
+ const values = declaredValues(member.node, discriminant);
85
+ if (values === undefined) refuseUndeclared(discriminant, index, member.node.kind);
86
+ const check = checkOf(member) as Check<Out>;
87
+ for (const value of values) {
88
+ // Letting the first declaration win would leave a member that can never run, which is the
89
+ // same defect as an unreachable case and is invisible until the wrong branch validates.
90
+ if (branches.has(value)) refuseDuplicate(discriminant, index, value);
91
+ branches.set(value, check);
92
+ known.push(value);
93
+ }
94
+ }
95
+
96
+ const node: SchemaNode = {
97
+ kind: 'union',
98
+ discriminant,
99
+ anyOf: members.map((member) => member.node),
100
+ };
101
+
102
+ return makeSchema<InferInput<S[number]>, Out>(node, (value, path) => {
103
+ if (!isPlainObject(value)) {
104
+ return fail(path, expected(`an object with a "${discriminant}" discriminant`, value));
105
+ }
106
+ const branch = branches.get(value[discriminant]);
107
+ if (branch === undefined) {
108
+ return fail(
109
+ [...path, discriminant],
110
+ expected(`one of ${renderKnown(known)}`, value[discriminant]),
111
+ );
112
+ }
113
+ // The named branch's issues, and only those: the caller already said which shape they meant,
114
+ // so the other branches' complaints are noise that hides the one real field error.
115
+ return branch(value, path);
116
+ });
117
+ }
package/src/errors.ts CHANGED
@@ -20,6 +20,12 @@ export const SCHEMA_ERROR_CODES: Readonly<Record<string, SchemaErrorCodeDeclarat
20
20
  Object.freeze({
21
21
  X_VALIDATION_FAILED: { title: 'value did not match its schema' },
22
22
  X_SCHEMA_UNSUPPORTED: { title: 'the active schema provider cannot do this' },
23
+ X_SCHEMA_DISCRIMINANT_INVALID: {
24
+ title: 'a discriminated union member can never be dispatched to',
25
+ },
26
+ X_SCHEMA_DEFAULT_UNSHAREABLE: {
27
+ title: 'a schema default cannot be copied per parse',
28
+ },
23
29
  });
24
30
 
25
31
  /**
@@ -127,6 +133,20 @@ export class ValidationFailedError extends SchemaError {
127
133
  }
128
134
  }
129
135
 
136
+ /**
137
+ * Thrown where the union is BUILT, not where a value is parsed: a member no tag can route to is
138
+ * wrong for every input, so the first import of the authoring file is the earliest honest place
139
+ * to say so — never a request that quietly took the wrong branch.
140
+ */
141
+ export class DiscriminantInvalidError extends SchemaError {
142
+ static readonly code = 'X_SCHEMA_DISCRIMINANT_INVALID';
143
+ override readonly name = 'DiscriminantInvalidError';
144
+
145
+ constructor(init: Omit<SchemaErrorInit, 'code'>) {
146
+ super({ ...init, code: DiscriminantInvalidError.code });
147
+ }
148
+ }
149
+
130
150
  export class SchemaUnsupportedError extends SchemaError {
131
151
  static readonly code = 'X_SCHEMA_UNSUPPORTED';
132
152
  override readonly name = 'SchemaUnsupportedError';
package/src/index.ts CHANGED
@@ -7,24 +7,18 @@ export type {
7
7
  CheckOk,
8
8
  CheckResult,
9
9
  Path,
10
+ Refinement,
10
11
  Schema,
11
12
  Shape,
12
13
  ShapeInput,
13
14
  ShapeOutput,
14
15
  Simplify,
15
16
  } from './builder';
16
- export {
17
- checkOf,
18
- describeValue,
19
- expected,
20
- fail,
21
- failWith,
22
- makeSchema,
23
- pass,
24
- VENDOR,
25
- } from './builder';
17
+ export { checkOf, fail, failWith, makeSchema, pass, VENDOR } from './builder';
26
18
  export type { QuerySource } from './coerce';
27
19
  export { coerceInput, coerceNode, coerceQuery } from './coerce';
20
+ export { describeValue, expected } from './describe-value';
21
+ export { discriminatedUnionSchema } from './discriminated-union';
28
22
  export type {
29
23
  SchemaErrorCodeDeclaration,
30
24
  SchemaErrorInit,
@@ -32,6 +26,7 @@ export type {
32
26
  ValidationIssue,
33
27
  } from './errors';
34
28
  export {
29
+ DiscriminantInvalidError,
35
30
  isSchemaError,
36
31
  SCHEMA_ERROR_CODES,
37
32
  SchemaError,
@@ -42,11 +37,19 @@ export {
42
37
  export type {
43
38
  JsonSchema,
44
39
  JsonSchemaDialect,
40
+ JsonSchemaDiscriminator,
45
41
  JsonSchemaType,
46
42
  ToJsonSchemaOptions,
47
43
  } from './json-schema';
48
44
  export { nodeToJsonSchema, toJsonSchema, toMcpInputSchema } from './json-schema';
49
- export type { SchemaFormat, SchemaKind, SchemaNode } from './node';
45
+ export type { MoneyValue } from './money-value';
46
+ export {
47
+ CURRENCY_CODE_PATTERN,
48
+ isCurrencyCode,
49
+ isMoneyScale,
50
+ MAX_MONEY_SCALE,
51
+ } from './money-value';
52
+ export type { SchemaFormat, SchemaKind, SchemaNode, SchemaRefinement } from './node';
50
53
  export { isSchemaNode, nodeOf, requiredKeys } from './node';
51
54
  export type { SchemaProvider } from './provider';
52
55
  export {
@@ -83,7 +86,6 @@ export {
83
86
  export type { Infer } from './t';
84
87
  export { t } from './t';
85
88
  export type {
86
- MoneyValue,
87
89
  NumberSchema,
88
90
  ObjectSchema,
89
91
  StringSchema,
@@ -98,5 +100,6 @@ export {
98
100
  objectSchema,
99
101
  optionalSchema,
100
102
  recordSchema,
103
+ refineSchema,
101
104
  unionSchema,
102
105
  } from './validators';
@@ -2,10 +2,24 @@
2
2
  // bodies and MCP tool `inputSchema` are both this function's output, so an agent's view of an
3
3
  // action and an HTTP client's view can never drift.
4
4
 
5
- import { requiredKeys, type SchemaNode } from './node';
5
+ import { CURRENCY_CODE_PATTERN, MAX_MONEY_SCALE } from './money-value';
6
+ import { requiredKeys, type SchemaNode, type SchemaRefinement } from './node';
6
7
  import { introspect } from './provider';
7
8
 
8
- export type JsonSchemaType = 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array';
9
+ export type JsonSchemaType =
10
+ | 'string'
11
+ | 'number'
12
+ | 'integer'
13
+ | 'boolean'
14
+ | 'object'
15
+ | 'array'
16
+ /** Only ever emitted as the second branch of a nullable field's `anyOf` — see `annotate`. */
17
+ | 'null';
18
+
19
+ /** OpenAPI 3.1's tagged-union hint. `propertyName` alone: the branches here are inline, not `$ref`. */
20
+ export interface JsonSchemaDiscriminator {
21
+ readonly propertyName: string;
22
+ }
9
23
 
10
24
  export interface JsonSchema {
11
25
  readonly $schema?: string;
@@ -25,7 +39,14 @@ export interface JsonSchema {
25
39
  readonly additionalProperties?: boolean | JsonSchema;
26
40
  readonly items?: JsonSchema;
27
41
  readonly anyOf?: readonly JsonSchema[];
42
+ readonly discriminator?: JsonSchemaDiscriminator;
28
43
  readonly title?: string;
44
+ /**
45
+ * The refinements a consumer can act on mechanically. The prose copy also lands in
46
+ * `description`, because that is the only field an LLM reading an MCP tool schema is
47
+ * guaranteed to see — the extension is for code generators, the description is for readers.
48
+ */
49
+ readonly 'x-ultimate-refinements'?: readonly SchemaRefinement[];
29
50
  }
30
51
 
31
52
  export type JsonSchemaDialect = '2020-12' | 'draft-07';
@@ -64,12 +85,48 @@ function stringNode(node: SchemaNode): JsonSchema {
64
85
  };
65
86
  }
66
87
 
88
+ /**
89
+ * JSON Schema's `pattern` is an ECMA-262 source with no flag syntax, so a flagged pattern is
90
+ * stated in prose instead of silently narrowed: a consumer applying `pattern` alone would refuse
91
+ * values this schema accepts, and there is nowhere honest to hide that.
92
+ */
93
+ function patternNote(node: SchemaNode): string | undefined {
94
+ if (node.pattern === undefined) return undefined;
95
+ const flags = node.patternFlags;
96
+ return flags === undefined || flags === ''
97
+ ? undefined
98
+ : `pattern is applied with RegExp flags "${flags}"`;
99
+ }
100
+
67
101
  function convert(node: SchemaNode): JsonSchema {
68
- const annotate = (schema: JsonSchema): JsonSchema => ({
69
- ...schema,
70
- ...(node.description === undefined ? {} : { description: node.description }),
102
+ const refinements = node.refinements ?? [];
103
+ const notes = [
104
+ node.description,
105
+ patternNote(node),
106
+ ...refinements.map((refinement) => refinement.message),
107
+ ].filter((part): part is string => part !== undefined);
108
+ const described = notes.length === 0 ? undefined : notes.join(' — ');
109
+
110
+ const annotations: JsonSchema = {
111
+ ...(described === undefined ? {} : { description: described }),
71
112
  ...(node.hasDefault === true ? { default: node.default } : {}),
72
- });
113
+ ...(refinements.length === 0 ? {} : { 'x-ultimate-refinements': refinements }),
114
+ };
115
+
116
+ /**
117
+ * `null` is a VALUE the field holds, so it joins the type union rather than the annotations —
118
+ * `{ anyOf: [<converted>, { type: 'null' }] }`, which is how OpenAPI 3.1 / JSON Schema 2020-12
119
+ * spell it (3.0's `nullable: true` keyword is gone). Dropping it published a contract every
120
+ * surface at once disagreed with: OpenAPI bodies, MCP `inputSchema`, `respondToolFor` and the
121
+ * typed client all forbade a `null` the action's own `output:` validator returns.
122
+ *
123
+ * The annotations stay OUTSIDE the `anyOf` — they describe the field, not one branch of it —
124
+ * and `requiredKeys` is untouched, because nullable is not optional: the key is still sent.
125
+ */
126
+ const annotate = (schema: JsonSchema): JsonSchema =>
127
+ node.nullable === true
128
+ ? { anyOf: [schema, { type: 'null' }], ...annotations }
129
+ : { ...schema, ...annotations };
73
130
 
74
131
  switch (node.kind) {
75
132
  case 'string':
@@ -94,7 +151,14 @@ function convert(node: SchemaNode): JsonSchema {
94
151
  items: node.items === undefined ? {} : convert(node.items),
95
152
  });
96
153
  case 'union':
97
- return annotate({ anyOf: (node.anyOf ?? []).map(convert) });
154
+ return annotate({
155
+ anyOf: (node.anyOf ?? []).map(convert),
156
+ // Only when the union actually dispatches on a key: an untagged `t.union` carrying a
157
+ // `discriminator` would tell a generator to read a property no member declares.
158
+ ...(node.discriminant === undefined
159
+ ? {}
160
+ : { discriminator: { propertyName: node.discriminant } }),
161
+ });
98
162
  case 'record':
99
163
  return annotate({
100
164
  type: 'object',
@@ -104,8 +168,26 @@ function convert(node: SchemaNode): JsonSchema {
104
168
  return annotate({
105
169
  type: 'object',
106
170
  properties: {
107
- minor: { type: 'integer', description: 'amount in minor units, never a float' },
108
- currency: { type: 'string', pattern: '^[A-Z]{3}$' },
171
+ minor: {
172
+ type: 'integer',
173
+ description: 'amount in minor units, never a float',
174
+ // The safe-integer range the validator enforces, so a generated client refuses the
175
+ // same value the boundary does instead of learning about it from a 500.
176
+ minimum: -Number.MAX_SAFE_INTEGER,
177
+ maximum: Number.MAX_SAFE_INTEGER,
178
+ },
179
+ // The pattern the validator applies, not a copy of it: this object IS the contract a
180
+ // generated client checks against, so a widened predicate here would be a client
181
+ // refusing a code the boundary accepts.
182
+ currency: { type: 'string', pattern: CURRENCY_CODE_PATTERN },
183
+ // Optional, never required: `additionalProperties: false` alone would make a generated
184
+ // client refuse a scaled amount the framework's own validator accepts.
185
+ scale: {
186
+ type: 'integer',
187
+ description: 'decimal places `minor` counts; absent means the currency’s own',
188
+ minimum: 0,
189
+ maximum: MAX_MONEY_SCALE,
190
+ },
109
191
  },
110
192
  required: ['minor', 'currency'],
111
193
  additionalProperties: false,
@@ -0,0 +1,142 @@
1
+ // Single responsibility: the framework's ONE money declaration and the validator that guards it.
2
+ // Split out of `validators.ts` because it is the only builtin whose *shape* other packages alias
3
+ // — `@ultimat3/money`'s `Money`, `@ultimat3/entity`'s `MoneyValue` — so it earns a file a reader
4
+ // can open by name instead of scrolling to.
5
+
6
+ import { fail, failWith, isPlainObject, makeSchema, pass, type Schema } from './builder';
7
+ import { expected } from './describe-value';
8
+ import type { StandardIssue } from './standard';
9
+
10
+ /**
11
+ * The shape of an ISO 4217 alphabetic code, as a pattern SOURCE — the one declaration every
12
+ * projection of that bound derives from. It is a string rather than a `RegExp` because the two
13
+ * surfaces that cannot call a predicate need the source itself: `json-schema.ts` emits it as the
14
+ * `pattern` of the published OpenAPI contract, and `@ultimat3/entity`'s `currencyCheck` emits it
15
+ * inside a Postgres `~` CHECK so a psql session cannot write a code the app would refuse to read.
16
+ *
17
+ * Keep it inside the syntax ECMAScript, JSON Schema and POSIX ERE spell identically — anchors,
18
+ * a literal character class, a bounded repetition. A construct only one of the three understands
19
+ * (`\d`, a lookahead, a non-greedy quantifier) makes the CHECK stop meaning what `isCurrencyCode`
20
+ * means, and a real server is the first thing that says so.
21
+ */
22
+ export const CURRENCY_CODE_PATTERN = '^[A-Z]{3}$';
23
+
24
+ const CURRENCY_RE = new RegExp(CURRENCY_CODE_PATTERN);
25
+
26
+ /**
27
+ * The framework's ONE declaration of a money value. `@ultimat3/money`'s `Money` and
28
+ * `@ultimat3/entity`'s `MoneyValue` are aliases of this type, not copies of its shape — three
29
+ * structural restatements are how `minor` became a `number` here and a `bigint` there, which made
30
+ * a row the entity layer produced fail `t.money` and throw inside `JSON.stringify`.
31
+ *
32
+ * It lives at tier 0 because that is the only tier every other package may import, and `number`
33
+ * rather than `bigint` because money crosses the wire on every surface this framework projects —
34
+ * `JSON.stringify` refuses a bigint, and this node is also the OpenAPI contract. A value past
35
+ * `Number.MAX_SAFE_INTEGER` is refused HERE, at the boundary, with the field path — and again
36
+ * where it is decoded; it is never widened.
37
+ *
38
+ * Never a float, and never an amount without its currency.
39
+ */
40
+ export interface MoneyValue {
41
+ readonly minor: number;
42
+ readonly currency: string;
43
+ /**
44
+ * Decimal places `minor` counts, when they are not the currency's own. Absent — the shape every
45
+ * existing value and every existing row still has — means the currency's natural minor unit: 2
46
+ * for USD, 0 for JPY, 3 for KWD. `{ minor: 2, currency: 'USD', scale: 6 }` is $0.000002.
47
+ *
48
+ * It exists because a cents-only value could not name a sub-cent amount at all, so the one
49
+ * place that needed one — a model call costing $0.00016 — rounded it up to a whole cent and
50
+ * reported 62x the real spend. The alternative was a second money type, which is the axiom-1
51
+ * violation this declaration exists to prevent.
52
+ */
53
+ readonly scale?: number;
54
+ }
55
+
56
+ /**
57
+ * The largest decimal exponent a money value may carry. 10^15 is the last power of ten that is
58
+ * itself a safe integer, so a finer scale could not name its own unit inside the range `minor` is
59
+ * already checked against.
60
+ */
61
+ export const MAX_MONEY_SCALE = 15;
62
+
63
+ /**
64
+ * What a legal `MoneyValue.currency` is — declared once, here, beside the type that carries it and
65
+ * beside `isMoneyScale`, which is the twin of this predicate and the precedent for it. Takes
66
+ * `unknown` because every caller is a boundary: a row off a `char(3)` column, a body off the wire,
67
+ * a `registerCurrency` argument from an untyped caller. `String(value).test(…)` on a symbol throws
68
+ * where a refusal was due, so the `typeof` half belongs in here rather than at each call.
69
+ */
70
+ export function isCurrencyCode(value: unknown): value is string {
71
+ return typeof value === 'string' && CURRENCY_RE.test(value);
72
+ }
73
+
74
+ /** What a legal `MoneyValue.scale` is — declared once, here, beside the type that carries it. */
75
+ export function isMoneyScale(value: unknown): value is number {
76
+ return (
77
+ typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= MAX_MONEY_SCALE
78
+ );
79
+ }
80
+
81
+ export const moneySchema: Schema<MoneyValue, MoneyValue> = makeSchema<MoneyValue, MoneyValue>(
82
+ {
83
+ kind: 'money',
84
+ description: 'integer minor units plus an ISO 4217 currency code',
85
+ properties: {
86
+ minor: {
87
+ kind: 'number',
88
+ integer: true,
89
+ minimum: -Number.MAX_SAFE_INTEGER,
90
+ maximum: Number.MAX_SAFE_INTEGER,
91
+ },
92
+ currency: { kind: 'string', pattern: CURRENCY_CODE_PATTERN },
93
+ scale: {
94
+ kind: 'number',
95
+ integer: true,
96
+ optional: true,
97
+ minimum: 0,
98
+ maximum: MAX_MONEY_SCALE,
99
+ description: 'decimal places `minor` counts; absent means the currency’s own',
100
+ },
101
+ },
102
+ },
103
+ (value, path) => {
104
+ if (!isPlainObject(value)) return fail(path, expected('a Money object', value));
105
+ const minor = value['minor'];
106
+ const currency = value['currency'];
107
+ const scale = value['scale'];
108
+ const issues: StandardIssue[] = [];
109
+ // Safe, not merely whole: `money()` and `entity`'s `parseMinor` both demand a safe integer, so
110
+ // `Number.isInteger` here let 2^53 through the boundary as a 200 and failed at the row write
111
+ // as a 500 — the same value refused twice, once with a field path and once without.
112
+ if (typeof minor !== 'number' || !Number.isSafeInteger(minor)) {
113
+ issues.push({
114
+ message: expected('a safe integer number of minor units', minor),
115
+ path: [...path, 'minor'],
116
+ });
117
+ }
118
+ if (!isCurrencyCode(currency)) {
119
+ issues.push({
120
+ message: expected('a 3-letter ISO 4217 code', currency),
121
+ path: [...path, 'currency'],
122
+ });
123
+ }
124
+ if (scale !== undefined && !isMoneyScale(scale)) {
125
+ issues.push({
126
+ message: expected(
127
+ `a whole number of decimal places between 0 and ${MAX_MONEY_SCALE}`,
128
+ scale,
129
+ ),
130
+ path: [...path, 'scale'],
131
+ });
132
+ }
133
+ if (issues.length > 0) return failWith(issues);
134
+ // The key is carried only when it was sent: a value at the currency's own scale must
135
+ // round-trip byte-for-byte, or every stored amount in every app changes shape on one parse.
136
+ return pass({
137
+ minor: minor as number,
138
+ currency: currency as string,
139
+ ...(scale === undefined ? {} : { scale: scale as number }),
140
+ });
141
+ },
142
+ );
package/src/node.ts CHANGED
@@ -27,6 +27,17 @@ export type SchemaFormat =
27
27
  | 'locale'
28
28
  | 'cursor';
29
29
 
30
+ /**
31
+ * A refinement as the IR carries it: the predicate's *declaration*, never the predicate. A closure
32
+ * cannot cross into OpenAPI or an MCP tool schema, so what ships is the name and the rule text —
33
+ * enough for a generated client to state the constraint and for a form to label the failure.
34
+ */
35
+ export interface SchemaRefinement {
36
+ readonly name: string;
37
+ readonly message: string;
38
+ readonly path?: readonly string[] | undefined;
39
+ }
40
+
30
41
  export interface SchemaNode {
31
42
  readonly kind: SchemaKind;
32
43
  readonly optional?: boolean | undefined;
@@ -40,6 +51,12 @@ export interface SchemaNode {
40
51
  readonly maxLength?: number | undefined;
41
52
  /** Source string of the RegExp, so the node stays JSON-serialisable. */
42
53
  readonly pattern?: string | undefined;
54
+ /**
55
+ * The RegExp's flags, carried beside the source for the same reason. Dropping them made
56
+ * `t.string.pattern(/^[a-z]+$/i)` reject `ABC` while quoting the pattern that matches it.
57
+ * JSON Schema's `pattern` has no flags, so `json-schema.ts` states them in `description`.
58
+ */
59
+ readonly patternFlags?: string | undefined;
43
60
  readonly minimum?: number | undefined;
44
61
  readonly maximum?: number | undefined;
45
62
  readonly integer?: boolean | undefined;
@@ -48,7 +65,19 @@ export interface SchemaNode {
48
65
  readonly values?: readonly (string | number)[] | undefined;
49
66
  readonly literal?: string | number | boolean | null | undefined;
50
67
  readonly anyOf?: readonly SchemaNode[] | undefined;
68
+ /**
69
+ * The key a `union` dispatches on. Additive rather than a `'discriminatedUnion'` kind: every
70
+ * consumer that switches on `kind` — `json-schema.ts`, `coerce.ts`, `action`'s sample generator,
71
+ * the admin form generator — already handles `'union'` correctly, and a new kind would have
72
+ * fallen through each of their `default:` branches to an empty schema without failing anything.
73
+ */
74
+ readonly discriminant?: string | undefined;
51
75
  readonly valueNode?: SchemaNode | undefined;
76
+ /**
77
+ * Rules the structural fields cannot state. Carried beside `kind` rather than wrapping it for
78
+ * the reason `discriminant` is: a refined string must still read as a string everywhere.
79
+ */
80
+ readonly refinements?: readonly SchemaRefinement[] | undefined;
52
81
  }
53
82
 
54
83
  export function isSchemaNode(value: unknown): value is SchemaNode {
package/src/provider.ts CHANGED
@@ -11,8 +11,10 @@ export interface SchemaProvider {
11
11
  readonly vendor: string;
12
12
  readonly t: TNamespace;
13
13
  /**
14
- * Return the IR for one of this provider's schemas. Required for OpenAPI and MCP tool
15
- * schemas; omit it only if the provider also supplies `toJsonSchema`.
14
+ * Return the IR for one of this provider's schemas. Required for OpenAPI, MCP tool schemas
15
+ * and the admin form generator: `toJsonSchema()` calls it unconditionally and throws
16
+ * `X_SCHEMA_UNSUPPORTED` without it. There is no second way to describe a schema — the IR is
17
+ * the one projection surface, and every generator reads it.
16
18
  */
17
19
  introspect?(schema: unknown): SchemaNode | undefined;
18
20
  }
package/src/t.ts CHANGED
@@ -2,16 +2,11 @@
2
2
  // Every member delegates to the active provider, so `configureSchemaProvider()` takes effect
3
3
  // even for modules that captured `t` at import time.
4
4
 
5
- import type { AnySchema, Schema, Shape } from './builder';
5
+ import type { AnySchema, Refinement, Schema, Shape } from './builder';
6
+ import type { MoneyValue } from './money-value';
6
7
  import { schemaProvider } from './provider';
7
8
  import type { InferInput, InferOutput, StandardSchemaV1 } from './standard';
8
- import type {
9
- MoneyValue,
10
- NumberSchema,
11
- ObjectSchema,
12
- StringSchema,
13
- TNamespace,
14
- } from './validators';
9
+ import type { NumberSchema, ObjectSchema, StringSchema, TNamespace } from './validators';
15
10
 
16
11
  function provider(): TNamespace {
17
12
  return schemaProvider().t;
@@ -81,6 +76,28 @@ export const t: TNamespace = {
81
76
  ): Schema<InferInput<S[number]>, InferOutput<S[number]>> {
82
77
  return provider().union(...members);
83
78
  },
79
+ /**
80
+ * `t.discriminatedUnion('kind', postBody, pageBody)` — the blessed spelling for a tagged shape.
81
+ * A failure reports the branch `kind` named, so the caller reads one field error instead of
82
+ * every branch's reasons concatenated.
83
+ */
84
+ discriminatedUnion<S extends readonly [AnySchema, ...AnySchema[]]>(
85
+ discriminant: string,
86
+ ...members: S
87
+ ): Schema<InferInput<S[number]>, InferOutput<S[number]>> {
88
+ return provider().discriminatedUnion(discriminant, ...members);
89
+ },
90
+ /**
91
+ * `t.refine(range, { name: 'end-after-start', message: 'endDate must be after startDate', … })`
92
+ * — a cross-field rule declared ON the schema, so OpenAPI, the MCP tool schema and the form
93
+ * binding all state it instead of it living in a handler where no projection can see it.
94
+ *
95
+ * Delegates to the method for the reason `t.nullable` does: `refine` composes an existing
96
+ * schema rather than building one, so a swapped provider's schemas keep their own behaviour.
97
+ */
98
+ refine<In, Out>(schema: Schema<In, Out>, refinement: Refinement<Out>): Schema<In, Out> {
99
+ return schema.refine(refinement);
100
+ },
84
101
  record<S extends AnySchema>(
85
102
  values: S,
86
103
  ): Schema<Readonly<Record<string, InferInput<S>>>, Record<string, InferOutput<S>>> {