From f9b1f564ad949a81a8407fd90ecc14d497bb445d Mon Sep 17 00:00:00 2001
From: Andrew Zolotukhin
Date: Mon, 28 Sep 2026 20:23:01 +0000
Subject: [PATCH 1/5] feat(schema): compose typed schemas across boundaries
---
.changeset/schema-boundaries.md | 12 +
libs/knex-schema/src/entity.ts | 32 +-
libs/schema-json/README.md | 11 +
libs/schema-json/src/boundaries.test.ts | 108 ++++
libs/schema-json/src/standardJsonSchema.ts | 25 +-
libs/schema-json/src/toJsonSchema.ts | 85 ++-
libs/schema-json/src/types.ts | 2 +
libs/schema/BOUNDARIES.md | 151 +++++
libs/schema/README.md | 9 +
libs/schema/package.json | 3 +-
libs/schema/src/boundaries-docs.test.ts | 62 ++
.../schema/src/builders/ArraySchemaBuilder.ts | 8 +-
.../builders/BoundarySchemaBuilder.test.ts | 167 ++++++
.../src/builders/BoundarySchemaBuilder.ts | 528 ++++++++++++++++++
.../src/builders/ExternSchemaBuilder.ts | 3 +-
.../src/builders/IntersectionSchemaBuilder.ts | 4 +-
libs/schema/src/builders/LazySchemaBuilder.ts | 48 +-
.../src/builders/ObjectSchemaBuilder.ts | 49 +-
.../src/builders/RecordSchemaBuilder.ts | 4 +-
libs/schema/src/builders/SchemaBuilder.ts | 205 ++++---
.../schema/src/builders/TupleSchemaBuilder.ts | 9 +-
.../schema/src/builders/UnionSchemaBuilder.ts | 4 +-
libs/schema/src/builders/boundaries.test-d.ts | 102 ++++
libs/schema/src/core.ts | 7 +
libs/schema/tsconfig.build.json | 2 +-
libs/schema/tsconfig.typecheck.json | 6 +
libs/schema/vitest.config.mts | 12 +
libs/server-openapi/README.md | 12 +
libs/server-openapi/src/boundaries.test.ts | 102 ++++
.../src/generateAsyncApiSpec.ts | 17 +-
.../server-openapi/src/generateOpenApiSpec.ts | 41 +-
libs/server-openapi/src/schemaConverter.ts | 9 +-
libs/server-openapi/src/schemaRegistry.ts | 103 +++-
websites/docs/app/server-openapi/page.tsx | 13 +
websites/schema/app/docs/[[...slug]]/page.tsx | 2 +
websites/schema/app/docs/sections/index.ts | 6 +
.../app/docs/sections/schema-boundaries.tsx | 87 +++
websites/schema/app/schema-json/page.tsx | 13 +
38 files changed, 1871 insertions(+), 192 deletions(-)
create mode 100644 .changeset/schema-boundaries.md
create mode 100644 libs/schema-json/src/boundaries.test.ts
create mode 100644 libs/schema/BOUNDARIES.md
create mode 100644 libs/schema/src/boundaries-docs.test.ts
create mode 100644 libs/schema/src/builders/BoundarySchemaBuilder.test.ts
create mode 100644 libs/schema/src/builders/BoundarySchemaBuilder.ts
create mode 100644 libs/schema/src/builders/boundaries.test-d.ts
create mode 100644 libs/schema/tsconfig.typecheck.json
create mode 100644 libs/schema/vitest.config.mts
create mode 100644 libs/server-openapi/src/boundaries.test.ts
create mode 100644 websites/schema/app/docs/sections/schema-boundaries.tsx
diff --git a/.changeset/schema-boundaries.md b/.changeset/schema-boundaries.md
new file mode 100644
index 00000000..e5c45af5
--- /dev/null
+++ b/.changeset/schema-boundaries.md
@@ -0,0 +1,12 @@
+---
+"@cleverbrush/schema": minor
+"@cleverbrush/schema-json": minor
+"@cleverbrush/server-openapi": minor
+"@cleverbrush/knex-schema": minor
+---
+
+Add optional-aware fallbacks and preprocessing, immutable named schema references,
+and typed input/output decoding with nested inference and Standard Schema support.
+Generate directional JSON Schema/OpenAPI components with strict name collision
+checks. Preserve existing InferType output semantics and legacy optional null
+acceptance. Keep ORM navigation inference compatible with the richer schema types.
diff --git a/libs/knex-schema/src/entity.ts b/libs/knex-schema/src/entity.ts
index 93ff5896..4fbb5254 100644
--- a/libs/knex-schema/src/entity.ts
+++ b/libs/knex-schema/src/entity.ts
@@ -9,7 +9,6 @@
// extension (compatible with the existing query/include implementation).
import {
- type ArraySchemaBuilder,
type InferType,
ObjectSchemaBuilder,
type PropertyDescriptorTree,
@@ -129,18 +128,27 @@ export type EntityPropSelector<
*
* @public
*/
-export type UnwrapNavSchema =
- TProp extends ArraySchemaBuilder
- ? TEl extends ObjectSchemaBuilder
- ? TEl
+export type UnwrapNavSchema = TProp extends {
+ introspect(): { elementSchema: infer TEl };
+}
+ ? NonNullable extends ObjectSchemaBuilder<
+ any,
+ any,
+ any,
+ any,
+ any,
+ any,
+ any
+ >
+ ? NonNullable
+ : never
+ : TProp extends ObjectSchemaBuilder
+ ? TProp
+ : TProp extends SchemaBuilder
+ ? T extends ObjectSchemaBuilder
+ ? T
: never
- : TProp extends ObjectSchemaBuilder
- ? TProp
- : TProp extends SchemaBuilder
- ? T extends ObjectSchemaBuilder
- ? T
- : never
- : never;
+ : never;
/**
* Merge type for a single polymorphic variant branch: variant schema fields
diff --git a/libs/schema-json/README.md b/libs/schema-json/README.md
index 97bb3743..dacf6292 100644
--- a/libs/schema-json/README.md
+++ b/libs/schema-json/README.md
@@ -12,6 +12,17 @@ for use in OpenAPI specs, form generators, or any other JSON Schema consumer.
## When to use this library
+### Input and output views
+
+`toJsonSchema(schema, { mode: 'input' })` describes input before decoding/defaults;
+`mode: 'output'` (the default) describes validated output.
+`withStandardJsonSchema(schema)` exposes the same distinction through
+`jsonSchema.input()` and `jsonSchema.output()`. Converters are never executed.
+Their arbitrary logic is not representable in JSON Schema.
+
+Named reference wrappers preserve use-site annotations, optionality and nullability
+without changing the shared definition. See the [composition guide](../schema/BOUNDARIES.md).
+
- **Consuming external APIs** — you have a JSON Schema from an OpenAPI spec or
a third-party service and want to validate incoming data with full TypeScript
type inference.
diff --git a/libs/schema-json/src/boundaries.test.ts b/libs/schema-json/src/boundaries.test.ts
new file mode 100644
index 00000000..404978f9
--- /dev/null
+++ b/libs/schema-json/src/boundaries.test.ts
@@ -0,0 +1,108 @@
+import {
+ array,
+ decode,
+ lazy,
+ number,
+ object,
+ record,
+ schemaRef,
+ string,
+ tuple,
+ union
+} from '@cleverbrush/schema';
+import { describe, expect, it, vi } from 'vitest';
+import { withStandardJsonSchema } from './standardJsonSchema.js';
+import { toJsonSchema } from './toJsonSchema.js';
+
+describe('boundary JSON Schema views', () => {
+ it('does not transfer output nullability or output defaults to input', () => {
+ const schema = decode(string(), number().nullable(), () => null);
+ expect(toJsonSchema(schema, { $schema: false, mode: 'input' })).toEqual(
+ { allOf: [{ type: 'string' }] }
+ );
+ expect(toJsonSchema(schema, { $schema: false }).allOf).toEqual([
+ { type: ['integer', 'null'] }
+ ]);
+ const withDefault = decode(string(), number(), Number).default(3);
+ expect(
+ toJsonSchema(withDefault, { mode: 'input' }).default
+ ).toBeUndefined();
+ expect(toJsonSchema(withDefault).default).toBe(3);
+ });
+ it('exports declared input/output without running converters', () => {
+ const converter = vi.fn(Number);
+ const size = decode(
+ string().minLength(1),
+ number().isInteger().min(1),
+ converter
+ );
+ expect(toJsonSchema(size, { $schema: false, mode: 'input' })).toEqual({
+ allOf: [{ type: 'string', minLength: 1 }]
+ });
+ expect(toJsonSchema(size, { $schema: false })).toEqual({
+ allOf: [{ type: 'integer', minimum: 1 }]
+ });
+ const standard = withStandardJsonSchema(size)['~standard'].jsonSchema;
+ expect(standard.input({ target: 'draft-2020-12' })).not.toEqual(
+ standard.output({ target: 'draft-2020-12' })
+ );
+ expect(converter).not.toHaveBeenCalled();
+ });
+
+ it('tracks input defaults separately from required output', () => {
+ const size = decode(string().default('1'), number(), Number);
+ const schema = object({ size, title: string().default('new') });
+ expect(
+ toJsonSchema(schema, { mode: 'input' }).required
+ ).toBeUndefined();
+ expect(toJsonSchema(schema).required).toEqual(['size', 'title']);
+ });
+
+ it.each([
+ '2020-12',
+ '07'
+ ] as const)('keeps ref annotations outside the definition in draft %s', draft => {
+ const user = object({ name: string() }).schemaName('User');
+ const schema = object({
+ user: schemaRef(user),
+ previous: schemaRef(user)
+ .nullable()
+ .optional()
+ .describe('Previous user')
+ .example(null)
+ });
+ const json = toJsonSchema(schema, {
+ draft,
+ $schema: false,
+ nameResolver: s => (s === user ? 'User' : null)
+ }) as any;
+ expect(json.required).toEqual(['user']);
+ expect(json.properties.user).toEqual({
+ allOf: [{ $ref: '#/components/schemas/User' }]
+ });
+ expect(json.properties.previous).toMatchObject({
+ description: 'Previous user',
+ examples: [null],
+ anyOf: [
+ { allOf: [{ $ref: '#/components/schemas/User' }] },
+ { type: 'null' }
+ ]
+ });
+ expect(user.introspect().description).toBeUndefined();
+ });
+
+ it('projects nested containers and lazy wrappers', () => {
+ const size = decode(string(), number(), Number);
+ const schema = object({
+ list: array(size),
+ pair: tuple([size]),
+ values: record(string(), size),
+ option: union(size).or(string()),
+ later: lazy(() => size)
+ });
+ const input = JSON.stringify(toJsonSchema(schema, { mode: 'input' }));
+ const output = JSON.stringify(toJsonSchema(schema));
+ expect(input).not.toContain('"type":"integer"');
+ expect(output).toContain('"type":"integer"');
+ });
+});
diff --git a/libs/schema-json/src/standardJsonSchema.ts b/libs/schema-json/src/standardJsonSchema.ts
index b5e41e18..fb3ddc74 100644
--- a/libs/schema-json/src/standardJsonSchema.ts
+++ b/libs/schema-json/src/standardJsonSchema.ts
@@ -1,4 +1,8 @@
-import type { SchemaBuilder } from '@cleverbrush/schema';
+import type {
+ InferInput,
+ InferOutput,
+ SchemaBuilder
+} from '@cleverbrush/schema';
import type { StandardJSONSchemaV1 } from '@standard-schema/spec';
import { toJsonSchema } from './toJsonSchema.js';
@@ -31,9 +35,8 @@ function targetToOptions(target: StandardJSONSchemaV1.Target): {
* [Standard JSON Schema v1](https://standardschema.dev/) interface.
*
* The returned schema object's `~standard` property is enriched with a
- * `jsonSchema` converter. Because `@cleverbrush/schema` does not
- * distinguish between input and output types, both `input()` and `output()`
- * produce the same JSON Schema document.
+ * `jsonSchema` converter. Input describes values before decoding/defaults;
+ * output describes validated values. Converter functions are never executed.
*
* **Note:** this mutates the schema instance by overriding the `~standard`
* property. The returned reference is the same schema object.
@@ -56,13 +59,19 @@ function targetToOptions(target: StandardJSONSchemaV1.Target): {
*/
export function withStandardJsonSchema<
T extends SchemaBuilder
->(schema: T): T & StandardJSONSchemaV1 {
+>(schema: T): T & StandardJSONSchemaV1, InferOutput> {
const converter: StandardJSONSchemaV1.Converter = {
input(options: StandardJSONSchemaV1.Options): Record {
- return toJsonSchema(schema, targetToOptions(options.target));
+ return toJsonSchema(schema, {
+ ...targetToOptions(options.target),
+ mode: 'input'
+ });
},
output(options: StandardJSONSchemaV1.Options): Record {
- return toJsonSchema(schema, targetToOptions(options.target));
+ return toJsonSchema(schema, {
+ ...targetToOptions(options.target),
+ mode: 'output'
+ });
}
};
@@ -85,5 +94,5 @@ export function withStandardJsonSchema<
enumerable: false
});
- return schema as T & StandardJSONSchemaV1;
+ return schema as T & StandardJSONSchemaV1, InferOutput>;
}
diff --git a/libs/schema-json/src/toJsonSchema.ts b/libs/schema-json/src/toJsonSchema.ts
index a322e59b..fdb460b8 100644
--- a/libs/schema-json/src/toJsonSchema.ts
+++ b/libs/schema-json/src/toJsonSchema.ts
@@ -11,19 +11,56 @@ function escapeJsonPointerSegment(s: string): string {
return s.replace(/~/g, '~0').replace(/\//g, '~1');
}
+/** Whether a property must be present on the selected side of a boundary. */
+function isRequiredInMode(
+ schema: SchemaBuilder,
+ mode: 'input' | 'output'
+): boolean {
+ const info = schema.introspect() as any;
+ if (mode === 'input' && info.hasDefault) return false;
+ if (info.type === 'decode' || info.type === 'reference') {
+ if (info.presence !== undefined) return info.presence;
+ return isRequiredInMode(
+ mode === 'input' ? info.inputSchema : info.outputSchema,
+ mode
+ );
+ }
+ return info.isRequired !== false;
+}
+
type Resolver =
| ((schema: SchemaBuilder) => string | null)
| undefined;
function convertNodeInner(
schema: SchemaBuilder,
- resolver: Resolver
+ resolver: Resolver,
+ mode: 'input' | 'output'
): Out {
const info = schema.introspect() as any;
const ext: Record = info.extensions ?? {};
const readOnly: Out = info.isReadonly === true ? { readOnly: true } : {};
switch (info.type) {
+ case 'reference':
+ case 'decode': {
+ const target =
+ mode === 'input' ? info.inputSchema : info.outputSchema;
+ const out: Out = { allOf: [convertNode(target, resolver, mode)] };
+ if (info.nullability === false)
+ (out.allOf as Out[]).push({ not: { type: 'null' } });
+ return out;
+ }
+ case 'record':
+ return {
+ type: 'object',
+ propertyNames: convertNode(info.keySchema, resolver, mode),
+ additionalProperties: convertNode(
+ info.valueSchema,
+ resolver,
+ mode
+ )
+ };
case 'string': {
if (info.equalsTo !== undefined)
return { ...readOnly, const: info.equalsTo };
@@ -95,7 +132,7 @@ function convertNodeInner(
case 'array': {
const out: Out = { ...readOnly, type: 'array' };
if (info.elementSchema)
- out['items'] = convertNode(info.elementSchema, resolver);
+ out['items'] = convertNode(info.elementSchema, resolver, mode);
if (info.minLength !== undefined) out['minItems'] = info.minLength;
if (info.maxLength !== undefined) out['maxItems'] = info.maxLength;
if (ext['nonempty'] === true && out['minItems'] === undefined)
@@ -108,11 +145,11 @@ function convertNodeInner(
info.elements ?? [];
const out: Out = {
type: 'array',
- prefixItems: elements.map(e => convertNode(e, resolver)),
+ prefixItems: elements.map(e => convertNode(e, resolver, mode)),
minItems: elements.length
};
if (info.restSchema) {
- out['items'] = convertNode(info.restSchema, resolver);
+ out['items'] = convertNode(info.restSchema, resolver, mode);
} else {
out['items'] = false;
out['maxItems'] = elements.length;
@@ -129,9 +166,8 @@ function convertNodeInner(
const outProps: Record = {};
const required: string[] = [];
for (const [key, propSchema] of Object.entries(props)) {
- outProps[key] = convertNode(propSchema, resolver);
- if ((propSchema.introspect() as any).isRequired !== false)
- required.push(key);
+ outProps[key] = convertNode(propSchema, resolver, mode);
+ if (isRequiredInMode(propSchema, mode)) required.push(key);
}
out['properties'] = outProps;
if (required.length > 0) out['required'] = required;
@@ -164,7 +200,7 @@ function convertNodeInner(
}
if (allConst) return { ...readOnly, enum: enumValues };
- const converted = options.map(o => convertNode(o, resolver));
+ const converted = options.map(o => convertNode(o, resolver, mode));
const out: Out = {
...readOnly,
anyOf: converted
@@ -217,8 +253,8 @@ function convertNodeInner(
return {
...readOnly,
allOf: [
- convertNode(left, resolver),
- convertNode(right, resolver)
+ convertNode(left, resolver, mode),
+ convertNode(right, resolver, mode)
]
};
}
@@ -230,7 +266,7 @@ function convertNodeInner(
// Recursive schemas without a registered name will cause infinite
// recursion here; callers must use .schemaName() to break the cycle.
const resolved: SchemaBuilder = info.getter();
- return convertNode(resolved, resolver);
+ return convertNode(resolved, resolver, mode);
}
default:
@@ -240,7 +276,8 @@ function convertNodeInner(
function convertNode(
schema: SchemaBuilder,
- resolver: Resolver
+ resolver: Resolver,
+ mode: 'input' | 'output'
): Out {
if (resolver) {
const name = resolver(schema);
@@ -250,7 +287,7 @@ function convertNode(
};
}
}
- const out = convertNodeInner(schema, resolver);
+ const out = convertNodeInner(schema, resolver, mode);
const info = schema.introspect() as any;
if (typeof info.description === 'string' && info.description !== '')
out['description'] = info.description;
@@ -263,6 +300,10 @@ function convertNode(
// Emit default for serializable primitives (not factory functions)
if (
info.hasDefault === true &&
+ !(
+ mode === 'input' &&
+ (info.type === 'decode' || info.type === 'reference')
+ ) &&
info.defaultValue !== undefined &&
typeof info.defaultValue !== 'function'
) {
@@ -270,7 +311,11 @@ function convertNode(
}
// Handle nullable — JSON Schema 2020-12 style: type becomes an array
- if (info.isNullable === true) {
+ if (
+ info.type === 'reference' || info.type === 'decode'
+ ? info.nullability === true
+ : info.isNullable === true
+ ) {
if (out['anyOf'] !== undefined) {
// Union type — add { type: 'null' } to anyOf if not already present
const anyOf = out['anyOf'] as Out[];
@@ -278,7 +323,11 @@ function convertNode(
if (!hasNull) anyOf.push({ type: 'null' });
} else if (out['allOf'] !== undefined && out['type'] === undefined) {
// Intersection type without a top-level type — wrap in oneOf with null
- out['oneOf'] = [{ allOf: out['allOf'] as Out[] }, { type: 'null' }];
+ out[
+ info.type === 'reference' || info.type === 'decode'
+ ? 'anyOf'
+ : 'oneOf'
+ ] = [{ allOf: out['allOf'] as Out[] }, { type: 'null' }];
delete out['allOf'];
} else if (out['enum'] !== undefined) {
// Enum — add null to enum values if not already present
@@ -362,7 +411,11 @@ export function toJsonSchema(
schema: SchemaBuilder,
opts?: ToJsonSchemaOptions
): Record {
- const body = convertNode(schema, opts?.nameResolver);
+ const body = convertNode(
+ schema,
+ opts?.nameResolver,
+ opts?.mode ?? 'output'
+ );
if (opts?.$schema === false) return body;
const draft = opts?.draft ?? '2020-12';
const uri =
diff --git a/libs/schema-json/src/types.ts b/libs/schema-json/src/types.ts
index 089b9748..afb1f950 100644
--- a/libs/schema-json/src/types.ts
+++ b/libs/schema-json/src/types.ts
@@ -137,6 +137,8 @@ export type InferFromJsonSchema = S extends { readonly const: infer V }
/** Options accepted by {@link toJsonSchema}. */
export type ToJsonSchemaOptions = {
+ /** Declared input before decoding/defaults, or validated output. @default 'output' */
+ mode?: 'input' | 'output';
/**
* JSON Schema draft version to reference in the `$schema` header.
* @default '2020-12'
diff --git a/libs/schema/BOUNDARIES.md b/libs/schema/BOUNDARIES.md
new file mode 100644
index 00000000..589b6544
--- /dev/null
+++ b/libs/schema/BOUNDARIES.md
@@ -0,0 +1,151 @@
+# Schemas across boundaries
+
+These APIs are additive. `InferType` continues to mean validated output;
+existing builder generic arguments and legacy preprocessors keep their meaning.
+
+## Optional fallbacks
+
+Before, consumers needed a separate safeParse helper for each optional scalar.
+Now a fallback can use the optional or nullable schema's full output type:
+
+```ts
+import { boolean, object, string } from '@cleverbrush/schema';
+
+const optionalText = string().optional().catch(undefined);
+const ExternalRecord = object({
+ name: optionalText,
+ enabled: boolean().optional().catch(() => undefined)
+});
+ExternalRecord.parse({ name: 42, enabled: 'unknown' });
+// { name: undefined, enabled: undefined }
+```
+
+Fallbacks are opt-in. A malformed required root object still fails. Arrays do
+not silently discard invalid elements. A fallback factory runs only on failure.
+
+**Compatibility limitation:** legacy optional schemas also accept `null` at
+runtime, even though their inferred type does not include it. This release does
+not change that behavior. Consequently, `.optional().catch(undefined)` leaves
+`null` unchanged. Normalize it explicitly when the application requires this:
+
+```ts
+const normalizedText = string().optional()
+ .addPreprocessor(value => value == null ? undefined : value)
+ .catch(undefined);
+```
+
+Preprocessors can return optional/nullable values, including asynchronously.
+Their existing callback parameter typing is preserved; it is not a guarantee
+that unknown runtime input already has that type. Use explicit guards or
+`decode` at untrusted boundaries. Global strict null rejection is a separate,
+compatibility-sensitive follow-up.
+
+## One named definition, many annotated references
+
+Before, cloning `User.schemaName('User')` with `.optional()` created another
+named instance and conflicted during OpenAPI generation.
+
+```ts
+import { number, object, schemaRef, string } from '@cleverbrush/schema';
+
+const User = object({ id: number(), name: string() }).schemaName('User');
+const History = object({
+ current: schemaRef(User),
+ previous: schemaRef(User).nullable().optional()
+ .describe('The previous user, when known.')
+});
+```
+
+`schemaRef` requires a named target. Its local modifiers do not rename, clone,
+or mutate that target. References delegate validation and preserve nested
+property selectors/errors. Independent schemas sharing a name still conflict;
+there is no name-only deduplication. Use-site optionality controls omission;
+nullability controls null acceptance for the wrapper independently.
+
+JSON Schema/OpenAPI keeps one definition and uses reference composition for
+local annotations, examples and nullability, including Draft 07 references.
+
+## Declare both sides of a conversion
+
+```ts
+import { decode, type InferInput, type InferOutput, number, object, string }
+ from '@cleverbrush/schema';
+
+const PageSize = decode(
+ string(),
+ number().isInteger().min(1).max(100),
+ value => Number(value)
+);
+const SearchInput = object({
+ pageSize: PageSize,
+ term: string().default('')
+});
+
+type Editable = InferInput; // pageSize is string
+type Validated = InferOutput; // pageSize is number
+const request = SearchInput.parse({ pageSize: '20' });
+```
+
+Parsing validates the input, invokes the converter once, then validates its
+output. Converter exceptions/rejections become validation failures. Asynchronous
+converters require `parseAsync`/`validateAsync`; synchronous parsing rejects
+Promise-returning conversion. Runtime parsing always validates unknown values.
+
+Nested objects, arrays, tuples, records, unions, intersections, references and
+lazy schemas retain input/output inference. Recursive schemas still require
+explicit TypeScript annotations. Standard Schema exposes the corresponding
+input/output types. `InferOutput` is an alias for `InferType`.
+
+An input-schema default runs before conversion. A default on the boundary itself
+is an **output** default, validated without conversion. Optional/nullable
+use-site modifiers bypass conversion for their allowed sentinel values.
+
+### Application-agnostic example
+
+```ts
+const Priority = decode(
+ string().oneOf('low', 'normal', 'high'),
+ number().min(1).max(3),
+ value => ({ low: 1, normal: 2, high: 3 })[value]
+);
+const Ticket = object({ title: string().minLength(1), priority: Priority });
+Ticket.parse({ title: 'Improve documentation', priority: 'high' });
+// { title: 'Improve documentation', priority: 3 }
+```
+
+Conversion policy remains application-owned: trimming, empty strings, numeric
+precision, date formats and intentional data loss are not global defaults.
+
+## JSON Schema and OpenAPI
+
+```ts
+import { toJsonSchema, withStandardJsonSchema } from '@cleverbrush/schema-json';
+
+toJsonSchema(PageSize, { mode: 'input' }); // declared string shape
+toJsonSchema(PageSize, { mode: 'output' }); // constrained numeric shape
+toJsonSchema(PageSize); // output remains the default
+
+const standard = withStandardJsonSchema(PageSize)['~standard'];
+standard.jsonSchema.input({ target: 'draft-2020-12' });
+standard.jsonSchema.output({ target: 'draft-2020-12' });
+```
+
+Converters are never executed by schema export. JSON Schema describes the
+declared shapes, not arbitrary converter logic or opaque custom validators.
+
+OpenAPI uses input views for requests and output views for responses. Named
+schemas with different views receive `NameInput` and `NameOutput` components;
+unchanged views retain their original name. Generated-name collisions throw,
+including collisions propagated through nested named or recursive references.
+AsyncAPI incoming/outgoing payloads use the same directional model.
+
+## Adoption boundary
+
+This release does **not** redesign React form state or typed HTTP client request
+inference. Those consumers may still assume input equals output. Do not treat a
+decoder as a drop-in shared form/endpoint contract for those APIs.
+
+Until that adoption, keep an explicit wire schema in shared contracts and decode
+inside the application boundary/handler, then return the validated output DTO.
+A decoder is not an encoder: no reverse conversion is inferred for requests,
+URLs, response serialization, database writes or editable form state.
diff --git a/libs/schema/README.md b/libs/schema/README.md
index 0fc779c0..f925db2a 100644
--- a/libs/schema/README.md
+++ b/libs/schema/README.md
@@ -1,5 +1,14 @@
# @cleverbrush/schema
+## Schemas across boundaries
+
+Use optional-aware `catch(undefined)`, `schemaRef(namedSchema)` for per-use
+annotations, and `decode(inputSchema, outputSchema, converter)` for explicit
+conversion. `InferInput` describes editable input; `InferOutput` and the existing
+`InferType` describe validated output. See the [boundary composition guide](./BOUNDARIES.md)
+for examples, null-compatibility limitations, and the separate form/client
+adoption boundary.
+
[](https://github.com/cleverbrush/framework/actions/workflows/ci.yml)
[](https://standardschema.dev/)
diff --git a/libs/schema/package.json b/libs/schema/package.json
index c89f8fcc..dce29b43 100644
--- a/libs/schema/package.json
+++ b/libs/schema/package.json
@@ -6,7 +6,8 @@
},
"description": "Schema Definition And Validation library, that allows to define and validate objects of any complexity",
"files": [
- "dist"
+ "dist",
+ "BOUNDARIES.md"
],
"homepage": "https://schema.cleverbrush.com/docs/getting-started",
"keywords": [
diff --git a/libs/schema/src/boundaries-docs.test.ts b/libs/schema/src/boundaries-docs.test.ts
new file mode 100644
index 00000000..be8378e4
--- /dev/null
+++ b/libs/schema/src/boundaries-docs.test.ts
@@ -0,0 +1,62 @@
+import { readFileSync } from 'node:fs';
+import ts from 'typescript';
+import { describe, expect, it } from 'vitest';
+
+describe('published boundary documentation', () => {
+ it('preserves summaries for new APIs, types and public boundary members', () => {
+ const missing: string[] = [];
+ for (const [path, names] of [
+ [
+ '../dist/builders/BoundarySchemaBuilder.d.ts',
+ ['BoundarySchemaBuilder', 'schemaRef', 'decode']
+ ],
+ [
+ '../dist/builders/SchemaBuilder.d.ts',
+ ['InferInput', 'InferOutput']
+ ],
+ ['../../schema-json/dist/types.d.ts', ['ToJsonSchemaOptions']]
+ ] as const) {
+ const text = readFileSync(new URL(path, import.meta.url), 'utf8');
+ const source = ts.createSourceFile(
+ path,
+ text,
+ ts.ScriptTarget.Latest,
+ true
+ );
+ const hasSummary = (node: ts.Node) =>
+ (node as ts.Node & { jsDoc?: readonly ts.JSDoc[] }).jsDoc?.some(
+ doc => doc.comment
+ );
+ for (const node of source.statements) {
+ const name =
+ 'name' in node && node.name
+ ? (node.name as ts.Identifier).text
+ : '';
+ if (!(names as readonly string[]).includes(name)) continue;
+ if (!hasSummary(node)) missing.push(name);
+ if (ts.isClassDeclaration(node))
+ for (const member of node.members) {
+ if (
+ ts.getCombinedModifierFlags(member) &
+ (ts.ModifierFlags.Private |
+ ts.ModifierFlags.Protected)
+ )
+ continue;
+ if (member.name && ts.isPrivateIdentifier(member.name))
+ continue;
+ if (
+ ts
+ .getJSDocTags(member)
+ .some(tag => tag.tagName.text === 'internal')
+ )
+ continue;
+ if (!hasSummary(member))
+ missing.push(
+ name + '.' + member.name?.getText(source)
+ );
+ }
+ }
+ }
+ expect(missing).toEqual([]);
+ });
+});
diff --git a/libs/schema/src/builders/ArraySchemaBuilder.ts b/libs/schema/src/builders/ArraySchemaBuilder.ts
index a044538b..bcbd575f 100644
--- a/libs/schema/src/builders/ArraySchemaBuilder.ts
+++ b/libs/schema/src/builders/ArraySchemaBuilder.ts
@@ -5,6 +5,7 @@ import type {
import {
type BRAND,
createHybridErrorArray,
+ type InferInput,
type InferType,
type NestedValidationResult,
type PropertyDescriptor,
@@ -101,8 +102,8 @@ export class ArraySchemaBuilder<
TResult = TExplicitType extends undefined
? TElementSchema extends undefined
? Array
- : TElementSchema extends SchemaBuilder
- ? Array>>
+ : TElementSchema extends SchemaBuilder
+ ? Array>
: never
: TExplicitType
> extends SchemaBuilder<
@@ -110,7 +111,8 @@ export class ArraySchemaBuilder<
TRequired,
TNullable,
THasDefault,
- TExtensions
+ TExtensions,
+ Array>
> {
#minLength?: number;
#defaultMinLengthErrorMessageProvider: ValidationErrorMessageProvider<
diff --git a/libs/schema/src/builders/BoundarySchemaBuilder.test.ts b/libs/schema/src/builders/BoundarySchemaBuilder.test.ts
new file mode 100644
index 00000000..b78c1b60
--- /dev/null
+++ b/libs/schema/src/builders/BoundarySchemaBuilder.test.ts
@@ -0,0 +1,167 @@
+import { describe, expect, it, vi } from 'vitest';
+import {
+ array,
+ boolean,
+ decode,
+ number,
+ object,
+ schemaRef,
+ string
+} from '../index.js';
+
+describe('schema boundaries', () => {
+ it('accepts optional/nullable fallbacks and preserves legacy null acceptance', async () => {
+ const text = string().optional().catch(undefined);
+ expect(text.parse(42)).toBeUndefined();
+ expect(await text.parseAsync(false)).toBeUndefined();
+ expect(text.parse(null)).toBeNull();
+ expect(string().nullable().catch(null).parse(42)).toBeNull();
+ const normalize = string()
+ .optional()
+ .addPreprocessor(v => (v == null ? undefined : v))
+ .catch(undefined);
+ expect(normalize.parse(null)).toBeUndefined();
+ expect(object({ text }).safeParse(null).valid).toBe(false);
+ expect(
+ object({
+ text,
+ flag: boolean()
+ .optional()
+ .catch(() => undefined)
+ }).parse({ text: {}, flag: 1 })
+ ).toEqual({ text: undefined, flag: undefined });
+ });
+
+ it('validates both sides and converts once', () => {
+ const convert = vi.fn(Number);
+ const size = decode(
+ string(),
+ number().isInteger().min(1).max(100),
+ convert
+ );
+ expect(size.parse('12')).toBe(12);
+ expect(convert).toHaveBeenCalledTimes(1);
+ expect(size.safeParse(12).valid).toBe(false);
+ expect(convert).toHaveBeenCalledTimes(1);
+ expect(size.safeParse('0').valid).toBe(false);
+ expect(size.safeParse('nope').valid).toBe(false);
+ expect(object({ size }).parse({ size: '5' })).toEqual({ size: 5 });
+ expect(array(size).parse(['2', '3'])).toEqual([2, 3]);
+ });
+
+ it('supports async conversion and captures converter failures', async () => {
+ const size = decode(string(), number(), async value => Number(value));
+ expect(() => size.parse('1')).toThrow(/parseAsync/);
+ expect(await size.parseAsync('2')).toBe(2);
+ expect(await size['~standard'].validate('3')).toEqual({ value: 3 });
+ const broken = decode(string(), number(), () => {
+ throw new Error('not convertible');
+ });
+ expect(broken.safeParse('x')).toMatchObject({
+ valid: false,
+ errors: [{ message: 'Decoder failed: not convertible' }]
+ });
+ const rejected = decode(string(), number(), async () => {
+ throw new Error('rejected');
+ });
+ expect((await rejected.safeParseAsync('x')).valid).toBe(false);
+ });
+
+ it('handles input defaults, output defaults and use-site modifiers', () => {
+ const size = decode(string().default('2'), number(), Number);
+ expect(size.parse(undefined)).toBe(2);
+ expect(size.optional().parse(undefined)).toBeUndefined();
+ expect(size.nullable().parse(null)).toBeNull();
+ expect(size.default(4).parse(undefined)).toBe(4);
+ expect(size.default(4).clearDefault().parse(undefined)).toBe(2);
+ expect(size.optional().required().safeParse(undefined).valid).toBe(
+ false
+ );
+ });
+
+ it('preserves one named definition and local metadata', () => {
+ const user = object({ name: string().minLength(1) }).schemaName('User');
+ const ref = schemaRef(user);
+ const previous = ref.nullable().optional().describe('Previous user');
+ expect(ref.introspect().outputSchema).toBe(user);
+ expect(previous.introspect().outputSchema).toBe(user);
+ expect(user.introspect().description).toBeUndefined();
+ expect(ref.safeParse(null).valid).toBe(false);
+ expect(previous.parse(null)).toBeNull();
+ expect(previous.parse(undefined)).toBeUndefined();
+ expect(previous.parse({ name: 'Ada' })).toEqual({ name: 'Ada' });
+ expect(() => schemaRef(string())).toThrow(/named schema/);
+ });
+
+ it('preserves nested reference errors and selectors', () => {
+ const user = object({ name: string().minLength(1) }).schemaName('User');
+ const result = object({ user: schemaRef(user) }).validate({
+ user: { name: '' }
+ });
+ expect(result.valid).toBe(false);
+ expect(
+ result.getErrorsFor(t => t.user.name).errors.length
+ ).toBeGreaterThan(0);
+ expect(
+ result.getInvalidProperties().map(p => p.descriptor.toJsonPointer())
+ ).toContain('/user/name');
+ });
+});
+
+describe('decoding boundary edge cases', () => {
+ it('uses output defaults and never re-runs conversion for catch', async () => {
+ const convert = vi.fn(() => undefined);
+ const schema = decode(string(), number().default(8), convert);
+ expect(schema.parse('missing')).toBe(8);
+ expect(convert).toHaveBeenCalledTimes(1);
+ const fail = vi.fn(() => {
+ throw new Error('conversion');
+ });
+ const fallback = decode(string(), number(), fail).catch(() => 4);
+ expect(fallback.parse('x')).toBe(4);
+ expect(await fallback.parseAsync('y')).toBe(4);
+ expect(fail).toHaveBeenCalledTimes(2);
+ });
+
+ it('keeps required messages and sync/async rules', async () => {
+ const schema = decode(string(), number(), Number).optional();
+ expect(
+ schema.required('Provide a size').safeParse(undefined).errors?.[0]
+ .message
+ ).toBe('Provide a size');
+ const asyncRequired = schema.required(async () => 'Async required');
+ expect(() => asyncRequired.parse(undefined)).toThrow(/Async|async/);
+ expect(
+ (await asyncRequired.safeParseAsync(undefined)).errors?.[0].message
+ ).toBe('Async required');
+ });
+
+ it('retains nested errors from both decoding stages', () => {
+ const schema = object({
+ item: decode(
+ object({ input: object({ value: string().minLength(1) }) }),
+ object({ output: object({ count: number().min(1) }) }),
+ input => ({ output: { count: Number(input.input.value) } })
+ )
+ });
+ const inputFailure = schema.validate({
+ item: { input: { value: '' } }
+ } as any);
+ expect(
+ inputFailure
+ .getInvalidProperties()
+ .map(p => p.descriptor.toJsonPointer())
+ ).toContain('/item/input/value');
+ const outputFailure = schema.validate({
+ item: { input: { value: '0' } }
+ } as any);
+ expect(
+ outputFailure.getErrorsFor(t => t.item.output.count).errors.length
+ ).toBeGreaterThan(0);
+ expect(
+ outputFailure
+ .getInvalidProperties()
+ .map(p => p.descriptor.toJsonPointer())
+ ).toContain('/item/output/count');
+ });
+});
diff --git a/libs/schema/src/builders/BoundarySchemaBuilder.ts b/libs/schema/src/builders/BoundarySchemaBuilder.ts
new file mode 100644
index 00000000..c9eac67b
--- /dev/null
+++ b/libs/schema/src/builders/BoundarySchemaBuilder.ts
@@ -0,0 +1,528 @@
+import {
+ type BRAND,
+ type InferInput,
+ type InferOutput,
+ SchemaBuilder,
+ type SchemaBuilderProps,
+ SYMBOL_HAS_PROPERTIES,
+ type ValidationContext,
+ type ValidationErrorMessageProvider,
+ type ValidationResult
+} from './SchemaBuilder.js';
+
+type AnySchema = SchemaBuilder;
+type BoundaryOutput<
+ TOutputSchema,
+ TRequired extends boolean,
+ TNullable extends boolean,
+ TExplicitType
+> =
+ | (TRequired extends true
+ ? Exclude<
+ TExplicitType extends undefined
+ ? InferOutput
+ : TExplicitType,
+ undefined
+ >
+ :
+ | (TExplicitType extends undefined
+ ? InferOutput
+ : TExplicitType)
+ | undefined)
+ | (TNullable extends true ? null : never);
+
+type BoundaryProps = Partial> & {
+ type: 'reference' | 'decode';
+ inputSchema: AnySchema;
+ outputSchema: AnySchema;
+ converter?: (value: any) => any;
+ presence?: boolean;
+ nullability?: boolean;
+};
+
+/**
+ * Immutable schema boundary returned by {@link schemaRef} and {@link decode}.
+ * Use the factories rather than constructing this class. Local modifiers never
+ * mutate the input/output schemas or their named definitions.
+ * @typeParam TInputSchema - Schema validating values before conversion.
+ * @typeParam TOutputSchema - Schema validating converted values.
+ * @typeParam TRequired - Whether the output includes undefined.
+ * @typeParam TNullable - Whether null is allowed by a use-site modifier.
+ * @typeParam THasDefault - Whether the wrapper supplies an output default.
+ * @typeParam TExplicitType - Optional static output override.
+ * @typeParam TInput - Declared input, including use-site presence modifiers.
+ */
+export class BoundarySchemaBuilder<
+ TInputSchema extends AnySchema,
+ TOutputSchema extends AnySchema,
+ TRequired extends boolean = true,
+ TNullable extends boolean = false,
+ THasDefault extends boolean = false,
+ TExplicitType = undefined,
+ TInput = InferInput
+> extends SchemaBuilder<
+ BoundaryOutput,
+ TRequired,
+ TNullable,
+ THasDefault,
+ {},
+ InferInput,
+ TInput | (THasDefault extends true ? undefined : never)
+> {
+ readonly #boundary: BoundaryProps;
+ /** @internal Enables nested property descriptors without subclassing objects. */
+ readonly [SYMBOL_HAS_PROPERTIES] = true;
+
+ /** @internal Factory configuration is exposed for immutable reconstruction. */
+ constructor(props: BoundaryProps) {
+ super({ preprocessors: [], validators: [], ...props });
+ this.#boundary = props;
+ }
+
+ /** Describes both sides without evaluating the converter. */
+ public introspect() {
+ return {
+ ...super.introspect(),
+ inputSchema: this.#boundary.inputSchema as TInputSchema,
+ outputSchema: this.#boundary.outputSchema as TOutputSchema,
+ converter: this.#boundary.converter,
+ presence: this.#boundary.presence,
+ nullability: this.#boundary.nullability,
+ // Object consumers can preserve real descriptor schemas through refs.
+ properties:
+ this.type === 'reference'
+ ? (this.#boundary.outputSchema.introspect() as any)
+ .properties
+ : undefined
+ };
+ }
+
+ /** @internal Rebuilds the wrapper while retaining the same target instances. */
+ protected createFromProps(props: BoundaryProps): this {
+ return new BoundarySchemaBuilder(props) as this;
+ }
+
+ #early(value: unknown): ValidationResult | undefined {
+ if (value === undefined && this.hasDefault) return undefined;
+ if (value === undefined && this.#boundary.presence !== undefined) {
+ return this.#boundary.presence
+ ? { valid: false, errors: [{ message: 'is required' }] }
+ : { valid: true, object: undefined };
+ }
+ if (value === null && this.#boundary.nullability !== undefined) {
+ return this.#boundary.nullability
+ ? { valid: true, object: null }
+ : { valid: false, errors: [{ message: 'must not be null' }] };
+ }
+ return undefined;
+ }
+
+ #converterFailure(error: unknown): ValidationResult {
+ return {
+ valid: false,
+ errors: [
+ {
+ message: `Decoder failed: ${error instanceof Error ? error.message : String(error)}`
+ }
+ ]
+ };
+ }
+
+ #stageFailure(
+ result: ValidationResult,
+ schema: AnySchema
+ ): ValidationResult {
+ return Object.assign(result, { __boundaryErrorSchema: schema });
+ }
+
+ /** Validates unknown input; an output fallback is never decoded a second time. */
+ public validate(
+ value: unknown,
+ context?: ValidationContext
+ ): ValidationResult<
+ BoundaryOutput
+ > {
+ const result = this._validate(value, context);
+ if (result.valid || !this.hasCatch) return result;
+ const fallback = this.resolveCatchValue();
+ const checked = this.#boundary.outputSchema.validate(fallback, context);
+ return checked.valid ? checked : { valid: true, object: fallback };
+ }
+
+ /** Async validation with the same output-fallback and error semantics as validate. */
+ public async validateAsync(
+ value: unknown,
+ context?: ValidationContext
+ ): Promise<
+ ValidationResult<
+ BoundaryOutput
+ >
+ > {
+ const result = await this._validateAsync(value, context);
+ if (result.valid || !this.hasCatch) return result;
+ const fallback = this.resolveCatchValue();
+ const checked = await this.#boundary.outputSchema.validateAsync(
+ fallback,
+ context
+ );
+ return checked.valid ? checked : { valid: true, object: fallback };
+ }
+
+ /** @internal Validates input, converts once, and validates output. */
+ protected _validate(
+ value: unknown,
+ context?: ValidationContext
+ ): ValidationResult {
+ const early = this.#early(value);
+ if (early) {
+ if (!early.valid && value === undefined)
+ early.errors = [
+ {
+ message: this.getValidationErrorMessageSync(
+ this.requiredErrorMessage,
+ value as any
+ )
+ }
+ ];
+ return early;
+ }
+ const usesDefault = value === undefined && this.hasDefault;
+ const input = usesDefault
+ ? { valid: true, object: this.resolveDefaultValue() }
+ : this.#boundary.inputSchema.validate(value, context);
+ if (!input.valid)
+ return this.#stageFailure(input, this.#boundary.inputSchema);
+ let converted = input.object;
+ if (!usesDefault && this.#boundary.converter) {
+ try {
+ converted = this.#boundary.converter(converted);
+ } catch (error) {
+ return this.#converterFailure(error);
+ }
+ if (converted != null && typeof converted.then === 'function') {
+ throw new Error(
+ 'Decoder returned a Promise. Use validateAsync() or parseAsync().'
+ );
+ }
+ }
+ const output =
+ this.type === 'reference' && !usesDefault
+ ? input
+ : this.#boundary.outputSchema.validate(converted, context);
+ if (!output.valid)
+ return this.#stageFailure(output, this.#boundary.outputSchema);
+ if (output.object === null && this.#boundary.nullability === false) {
+ return { valid: false, errors: [{ message: 'must not be null' }] };
+ }
+ const prepared = this.preValidateSync(output.object, context);
+ if (!prepared.valid) return { valid: false, errors: prepared.errors };
+ const preparedValue = prepared.transaction!.object.validatedObject;
+ if (this.preprocessors.length === 0) return output;
+ return this.#boundary.outputSchema.validate(preparedValue, context);
+ }
+
+ /** @internal Async counterpart; converter rejections are validation failures. */
+ protected async _validateAsync(
+ value: unknown,
+ context?: ValidationContext
+ ): Promise> {
+ const early = this.#early(value);
+ if (early) {
+ if (!early.valid && value === undefined)
+ early.errors = [
+ {
+ message: await this.getValidationErrorMessage(
+ this.requiredErrorMessage,
+ value as any
+ )
+ }
+ ];
+ return early;
+ }
+ const usesDefault = value === undefined && this.hasDefault;
+ const input = usesDefault
+ ? { valid: true, object: this.resolveDefaultValue() }
+ : await this.#boundary.inputSchema.validateAsync(value, context);
+ if (!input.valid)
+ return this.#stageFailure(input, this.#boundary.inputSchema);
+ let converted = input.object;
+ if (!usesDefault && this.#boundary.converter) {
+ try {
+ converted = await this.#boundary.converter(converted);
+ } catch (error) {
+ return this.#converterFailure(error);
+ }
+ }
+ const output =
+ this.type === 'reference' && !usesDefault
+ ? input
+ : await this.#boundary.outputSchema.validateAsync(
+ converted,
+ context
+ );
+ if (!output.valid)
+ return this.#stageFailure(output, this.#boundary.outputSchema);
+ if (output.object === null && this.#boundary.nullability === false) {
+ return { valid: false, errors: [{ message: 'must not be null' }] };
+ }
+ const prepared = await this.preValidateAsync(output.object, context);
+ if (!prepared.valid) return { valid: false, errors: prepared.errors };
+ const preparedValue = prepared.transaction!.object.validatedObject;
+ if (this.preprocessors.length === 0) return output;
+ return this.#boundary.outputSchema.validateAsync(
+ preparedValue,
+ context
+ );
+ }
+
+ /** Overrides only the static output type; runtime validation is unchanged. */
+ public hasType(
+ _notUsed?: T
+ ): BoundarySchemaBuilder<
+ TInputSchema,
+ TOutputSchema,
+ TRequired,
+ TNullable,
+ THasDefault,
+ T,
+ TInput
+ > {
+ return this.createFromProps(this.#props()) as any;
+ }
+
+ /** Restores output inference from the output schema. */
+ public clearHasType(): BoundarySchemaBuilder<
+ TInputSchema,
+ TOutputSchema,
+ TRequired,
+ TNullable,
+ THasDefault,
+ undefined,
+ TInput
+ > {
+ return this.createFromProps(this.#props()) as any;
+ }
+
+ #props(): BoundaryProps {
+ return {
+ ...this.#boundary,
+ ...this.introspect(),
+ type: this.#boundary.type,
+ preprocessors: [...this.preprocessors],
+ validators: [...this.validators]
+ };
+ }
+
+ /** Rejects missing input at this use site. */
+ public required(
+ errorMessage?: ValidationErrorMessageProvider
+ ): BoundarySchemaBuilder<
+ TInputSchema,
+ TOutputSchema,
+ true,
+ TNullable,
+ THasDefault,
+ TExplicitType,
+ Exclude
+ > {
+ return this.createFromProps({
+ ...this.#props(),
+ isRequired: true,
+ presence: true,
+ requiredValidationErrorMessageProvider: errorMessage
+ }) as any;
+ }
+
+ /** Allows omitted input at this use site, without invoking the boundary. */
+ public optional(): BoundarySchemaBuilder<
+ TInputSchema,
+ TOutputSchema,
+ false,
+ TNullable,
+ THasDefault,
+ TExplicitType,
+ TInput | undefined
+ > {
+ return this.createFromProps({
+ ...this.#props(),
+ isRequired: false,
+ presence: false
+ }) as any;
+ }
+
+ /** Allows null at this use site, independently of optionality. */
+ public nullable(): BoundarySchemaBuilder<
+ TInputSchema,
+ TOutputSchema,
+ TRequired,
+ true,
+ THasDefault,
+ TExplicitType,
+ TInput | null
+ > {
+ return this.createFromProps({
+ ...this.#props(),
+ isNullable: true,
+ nullability: true
+ }) as any;
+ }
+
+ /** Rejects null at this use site without changing the referenced definition. */
+ public notNullable(): BoundarySchemaBuilder<
+ TInputSchema,
+ TOutputSchema,
+ TRequired,
+ false,
+ THasDefault,
+ Exclude<
+ TExplicitType extends undefined
+ ? InferOutput
+ : TExplicitType,
+ null
+ >,
+ Exclude
+ > {
+ return this.createFromProps({
+ ...this.#props(),
+ isNullable: false,
+ nullability: false
+ }) as any;
+ }
+
+ /** Supplies an output default for missing input; validates it without decoding. */
+ public default(
+ value: InferOutput | (() => InferOutput)
+ ): BoundarySchemaBuilder<
+ TInputSchema,
+ TOutputSchema,
+ true,
+ TNullable,
+ true,
+ TExplicitType,
+ TInput
+ > {
+ return this.createFromProps({
+ ...this.#props(),
+ defaultValue: value,
+ isRequired: true
+ }) as any;
+ }
+
+ /** Removes the use-site default; defaults on the target are unchanged. */
+ public clearDefault(): BoundarySchemaBuilder<
+ TInputSchema,
+ TOutputSchema,
+ TRequired,
+ TNullable,
+ false,
+ TExplicitType,
+ TInput
+ > {
+ return this.createFromProps({
+ ...this.#props(),
+ defaultValue: undefined
+ }) as any;
+ }
+
+ /** Brands the output type only; it does not alter runtime validation. */
+ public brand(
+ _name?: B
+ ): BoundarySchemaBuilder<
+ TInputSchema,
+ TOutputSchema,
+ TRequired,
+ TNullable,
+ THasDefault,
+ (TExplicitType extends undefined
+ ? InferOutput
+ : TExplicitType) & {
+ readonly [K in BRAND]: B;
+ },
+ TInput
+ > {
+ return super.brand(_name);
+ }
+
+ /** Makes the output type readonly without freezing runtime values. */
+ public readonly(): BoundarySchemaBuilder<
+ TInputSchema,
+ TOutputSchema,
+ TRequired,
+ TNullable,
+ THasDefault,
+ Readonly<
+ TExplicitType extends undefined
+ ? InferOutput
+ : TExplicitType
+ >,
+ TInput
+ > {
+ return super.readonly();
+ }
+}
+
+type RequiredOf = undefined extends InferOutput ? false : true;
+type NullableOf = null extends InferOutput ? true : false;
+
+/**
+ * References one named definition with independent use-site annotations.
+ * @param schema - A reused schema constant with a nonempty schemaName.
+ * @returns An immutable reference wrapper; the named target is not cloned.
+ * @throws Error if the target has no name.
+ * @example
+ * const User = object({ name: string() }).schemaName('User');
+ * const previous = schemaRef(User).nullable().optional().describe('Previous user');
+ */
+export function schemaRef(
+ schema: S
+): BoundarySchemaBuilder, NullableOf> {
+ const info = schema.introspect();
+ if (!info.schemaName?.trim())
+ throw new Error('schemaRef requires a named schema.');
+ return new BoundarySchemaBuilder({
+ type: 'reference',
+ inputSchema: schema,
+ outputSchema: schema,
+ isRequired: info.isRequired,
+ isNullable: info.isNullable
+ });
+}
+
+/**
+ * Composes input validation, an explicit conversion, and output validation.
+ * @param inputSchema - Validates external input before the converter runs.
+ * @param outputSchema - Validates the converted result.
+ * @param converter - Receives validated input; may return a Promise.
+ * @returns A schema with distinct inferred input/output types.
+ * @remarks Converter exceptions become validation failures. Async converters
+ * require parseAsync/validateAsync. JSON Schema documents declared shapes only,
+ * not arbitrary conversion logic. This is not a bidirectional codec.
+ * @example
+ * const PageSize = decode(string(), number().isInteger().min(1), Number);
+ * PageSize.parse('12'); // 12
+ */
+export function decode<
+ TInputSchema extends AnySchema,
+ TOutputSchema extends AnySchema
+>(
+ inputSchema: TInputSchema,
+ outputSchema: TOutputSchema,
+ converter: (
+ input: InferOutput
+ ) => InferInput | Promise>
+): BoundarySchemaBuilder<
+ TInputSchema,
+ TOutputSchema,
+ RequiredOf,
+ NullableOf
+> {
+ const info = outputSchema.introspect();
+ return new BoundarySchemaBuilder({
+ type: 'decode',
+ inputSchema,
+ outputSchema,
+ converter,
+ isRequired: info.isRequired,
+ isNullable: info.isNullable
+ });
+}
diff --git a/libs/schema/src/builders/ExternSchemaBuilder.ts b/libs/schema/src/builders/ExternSchemaBuilder.ts
index 6d4fb12d..76fb55ec 100644
--- a/libs/schema/src/builders/ExternSchemaBuilder.ts
+++ b/libs/schema/src/builders/ExternSchemaBuilder.ts
@@ -100,7 +100,8 @@ export class ExternSchemaBuilder<
TRequired,
TNullable,
THasDefault,
- TExtensions
+ TExtensions,
+ StandardSchemaV1.InferInput
> {
#standardSchema: TStandardSchema;
diff --git a/libs/schema/src/builders/IntersectionSchemaBuilder.ts b/libs/schema/src/builders/IntersectionSchemaBuilder.ts
index 6d746c6a..47488a6b 100644
--- a/libs/schema/src/builders/IntersectionSchemaBuilder.ts
+++ b/libs/schema/src/builders/IntersectionSchemaBuilder.ts
@@ -1,5 +1,6 @@
import {
type BRAND,
+ type InferInput,
type InferType,
SchemaBuilder,
type ValidationContext,
@@ -36,7 +37,8 @@ export class IntersectionSchemaBuilder<
TRequired,
TNullable,
THasDefault,
- TExtensions
+ TExtensions,
+ InferInput & InferInput
> {
#left!: TLeft;
#right!: TRight;
diff --git a/libs/schema/src/builders/LazySchemaBuilder.ts b/libs/schema/src/builders/LazySchemaBuilder.ts
index 12f0d7c5..cc0ede6c 100644
--- a/libs/schema/src/builders/LazySchemaBuilder.ts
+++ b/libs/schema/src/builders/LazySchemaBuilder.ts
@@ -1,5 +1,7 @@
import {
type BRAND,
+ type InferInput,
+ type InferOutput,
SchemaBuilder,
type ValidationContext,
type ValidationErrorMessageProvider,
@@ -49,13 +51,15 @@ export class LazySchemaBuilder<
TRequired extends boolean = true,
TNullable extends boolean = false,
THasDefault extends boolean = false,
- TExtensions = {}
+ TExtensions = {},
+ TInput = TResult
> extends SchemaBuilder<
TResult,
TRequired,
TNullable,
THasDefault,
- TExtensions
+ TExtensions,
+ TInput
> {
#getter: () => SchemaBuilder;
#resolvedSchema: SchemaBuilder | null = null;
@@ -210,7 +214,7 @@ export class LazySchemaBuilder<
*/
public hasType(
_notUsed?: T
- ): LazySchemaBuilder &
+ ): LazySchemaBuilder &
TExtensions {
return this.createFromProps({
...this.introspect()
@@ -225,7 +229,8 @@ export class LazySchemaBuilder<
TRequired,
TNullable,
THasDefault,
- TExtensions
+ TExtensions,
+ TInput
> &
TExtensions {
return this.createFromProps({
@@ -238,7 +243,14 @@ export class LazySchemaBuilder<
*/
public required(
errorMessage?: ValidationErrorMessageProvider
- ): LazySchemaBuilder &
+ ): LazySchemaBuilder<
+ TResult,
+ true,
+ TNullable,
+ THasDefault,
+ TExtensions,
+ TInput
+ > &
TExtensions {
return super.required(errorMessage);
}
@@ -251,7 +263,8 @@ export class LazySchemaBuilder<
false,
TNullable,
THasDefault,
- TExtensions
+ TExtensions,
+ TInput
> &
TExtensions {
return super.optional();
@@ -262,7 +275,7 @@ export class LazySchemaBuilder<
*/
public default(
value: TResult | (() => TResult)
- ): LazySchemaBuilder &
+ ): LazySchemaBuilder &
TExtensions {
return super.default(value) as any;
}
@@ -275,7 +288,8 @@ export class LazySchemaBuilder<
TRequired,
TNullable,
false,
- TExtensions
+ TExtensions,
+ TInput
> &
TExtensions {
return super.clearDefault() as any;
@@ -291,7 +305,8 @@ export class LazySchemaBuilder<
TRequired,
TNullable,
THasDefault,
- TExtensions
+ TExtensions,
+ TInput
> &
TExtensions {
return super.brand(_name);
@@ -305,7 +320,8 @@ export class LazySchemaBuilder<
TRequired,
TNullable,
THasDefault,
- TExtensions
+ TExtensions,
+ TInput
> &
TExtensions {
return super.readonly();
@@ -319,7 +335,8 @@ export class LazySchemaBuilder<
TRequired,
true,
THasDefault,
- TExtensions
+ TExtensions,
+ TInput
> &
TExtensions {
return super.nullable() as any;
@@ -333,7 +350,8 @@ export class LazySchemaBuilder<
TRequired,
false,
THasDefault,
- TExtensions
+ TExtensions,
+ TInput
> &
TExtensions {
return super.notNullable() as any;
@@ -372,6 +390,12 @@ export class LazySchemaBuilder<
* });
* ```
*/
+export function lazy>(
+ getter: () => S
+): LazySchemaBuilder, true, false, false, {}, InferInput>;
+export function lazy(
+ getter: () => SchemaBuilder
+): LazySchemaBuilder;
export function lazy(
getter: () => SchemaBuilder
): LazySchemaBuilder {
diff --git a/libs/schema/src/builders/ObjectSchemaBuilder.ts b/libs/schema/src/builders/ObjectSchemaBuilder.ts
index d78d89d8..4ce3529a 100644
--- a/libs/schema/src/builders/ObjectSchemaBuilder.ts
+++ b/libs/schema/src/builders/ObjectSchemaBuilder.ts
@@ -1,6 +1,7 @@
import { PropertyValidationResult } from './PropertyValidationResult.js';
import {
type BRAND,
+ type InferInput,
type InferType,
type NestedValidationResult,
type PreValidationResult,
@@ -137,9 +138,9 @@ export type RespectPropsOptionality<
type RespectPropsOptionalityForInput<
T extends Record>
> = {
- [K in RequiredInputProps]: InferType;
+ [K in RequiredInputProps]: InferInput;
} & {
- [K in NotRequiredInputProps]?: InferType;
+ [K in NotRequiredInputProps]?: InferInput;
};
type MakeChildrenRequired<
@@ -376,7 +377,8 @@ export class ObjectSchemaBuilder<
TRequired,
TNullable,
THasDefault,
- TExtensions
+ TExtensions,
+ RespectPropsOptionalityForInput
> {
#properties: TProperties = {} as any;
#acceptUnknownProps = false;
@@ -1073,7 +1075,9 @@ export class ObjectSchemaBuilder<
ObjectSchemaBuilder.#propagateNestedErrors(
result as any,
descriptor,
- addErrorFor
+ addErrorFor,
+ (result as any).__boundaryErrorSchema ??
+ this.#properties[key]
);
// For extern schemas, also record errors on the extern
// descriptor itself so getErrorsFor(t => t.extern) works,
@@ -1842,7 +1846,8 @@ export class ObjectSchemaBuilder<
TRequired,
TNullable,
THasDefault,
- TExtensions
+ TExtensions,
+ RespectPropsOptionalityForInput
> {
return this.createFromProps({
...this.introspect()
@@ -2759,9 +2764,12 @@ export class ObjectSchemaBuilder<
descriptor: any,
message: string,
parentDescriptor?: any
- ) => void
+ ) => void,
+ schemaOverride?: SchemaBuilder
): void {
const schema =
+ schemaOverride ??
+ result.__boundaryErrorSchema ??
ObjectSchemaBuilder.#getSchemaForPropertyDescriptor(descriptor);
const properties = (schema.introspect() as any).properties;
@@ -2813,7 +2821,8 @@ export class ObjectSchemaBuilder<
ObjectSchemaBuilder.#propagateNestedErrors(
result,
nestedPropertyDescriptor,
- addErrorFor
+ addErrorFor,
+ nestedSchema
);
}
}
@@ -3114,33 +3123,11 @@ type NotRequiredProps<
type RequiredInputProps<
T extends Record>
> = keyof {
- [k in keyof T as T[k] extends SchemaBuilder<
- any,
- infer TReq,
- any,
- infer THasDef
- >
- ? TReq extends true
- ? THasDef extends true
- ? never
- : k
- : never
- : never]: T[k];
+ [k in keyof T as undefined extends InferInput ? never : k]: T[k];
};
type NotRequiredInputProps<
T extends Record>
> = keyof {
- [k in keyof T as T[k] extends SchemaBuilder<
- any,
- infer TReq,
- any,
- infer THasDef
- >
- ? TReq extends true
- ? THasDef extends true
- ? k
- : never
- : k
- : never]: T[k];
+ [k in keyof T as undefined extends InferInput ? k : never]: T[k];
};
diff --git a/libs/schema/src/builders/RecordSchemaBuilder.ts b/libs/schema/src/builders/RecordSchemaBuilder.ts
index f9e2ab63..5b0e306d 100644
--- a/libs/schema/src/builders/RecordSchemaBuilder.ts
+++ b/libs/schema/src/builders/RecordSchemaBuilder.ts
@@ -14,6 +14,7 @@
*/
import {
type BRAND,
+ type InferInput,
type InferType,
type PreValidationResult,
SchemaBuilder,
@@ -241,7 +242,8 @@ export class RecordSchemaBuilder<
TRequired,
TNullable,
THasDefault,
- TExtensions
+ TExtensions,
+ Record, InferInput>
> {
#keySchema!: TKeySchema;
#valueSchema!: TValueSchema;
diff --git a/libs/schema/src/builders/SchemaBuilder.ts b/libs/schema/src/builders/SchemaBuilder.ts
index 6b119bca..311e86ed 100644
--- a/libs/schema/src/builders/SchemaBuilder.ts
+++ b/libs/schema/src/builders/SchemaBuilder.ts
@@ -5,11 +5,12 @@ import {
transaction
} from '../utils/transaction.js';
import type { ArraySchemaBuilder } from './ArraySchemaBuilder.js';
-import type { ExternSchemaBuilder } from './ExternSchemaBuilder.js';
import type { ObjectSchemaBuilder } from './ObjectSchemaBuilder.js';
/** @internal Symbol used as the key for the type brand on schema builders. */
declare const __type: unique symbol;
+/** @internal Input type marker. */
+declare const __input: unique symbol;
/** @internal */
export type SchemaTypeBrand = typeof __type;
@@ -58,6 +59,18 @@ export type InferType = T extends {
? TType
: T;
+/** Validated output of a schema; compatibility alias for {@link InferType}. */
+export type InferOutput = InferType;
+
+/**
+ * Declared input before decoding and defaults. Parsers still validate unknown
+ * data at runtime. Legacy preprocessors do not infer a separate input type.
+ * @typeParam T - Schema whose input is required.
+ */
+export type InferInput = T extends { readonly [__input]: infer I }
+ ? I
+ : InferType;
+
/**
* Represents a single validation error with a human-readable error message.
*
@@ -538,77 +551,85 @@ export type PropertyDescriptorTree<
TParentPropertyDescriptor = undefined
> = PropertyDescriptor &
(TSchema extends ObjectSchemaBuilder
- ? {
- [K in keyof TProperties]: TProperties[K] extends ObjectSchemaBuilder<
- any,
- any,
- any
- >
- ? PropertyDescriptorTree<
- TProperties[K],
- TRootSchema,
- any,
- PropertyDescriptor<
+ ? 0 extends 1 & TProperties
+ ? { [key: string]: any }
+ : {
+ [K in keyof TProperties]: TProperties[K] extends ObjectSchemaBuilder<
+ any,
+ any,
+ any
+ >
+ ? PropertyDescriptorTree<
+ TProperties[K],
TRootSchema,
- TSchema,
- TParentPropertyDescriptor
+ any,
+ PropertyDescriptor<
+ TRootSchema,
+ TSchema,
+ TParentPropertyDescriptor
+ >
>
- >
- : TProperties[K] extends ExternSchemaBuilder<
- any,
- any,
- any,
- any,
- any,
- any,
- infer TExternResult
- >
- ? PropertyDescriptor<
- TRootSchema,
- TProperties[K],
- PropertyDescriptor<
- TRootSchema,
- TSchema,
- TParentPropertyDescriptor
- >,
- K & string
- > &
- ExternOutputPropertyDescriptors<
- TExternResult,
+ : TProperties[K] extends {
+ readonly [SYMBOL_HAS_PROPERTIES]: true;
+ }
+ ? PropertyDescriptor<
TRootSchema,
+ TProperties[K],
PropertyDescriptor<
TRootSchema,
- TProperties[K],
+ TSchema,
+ TParentPropertyDescriptor
+ >,
+ K & string
+ > &
+ ExternOutputPropertyDescriptors<
+ NonNullable>,
+ TRootSchema,
PropertyDescriptor<
TRootSchema,
- TSchema,
- TParentPropertyDescriptor
- >,
- K & string
+ TProperties[K],
+ PropertyDescriptor<
+ TRootSchema,
+ TSchema,
+ TParentPropertyDescriptor
+ >,
+ K & string
+ >
>
- >
- : TProperties[K] extends ArraySchemaBuilder<
- infer TArrayElement,
- any,
- any
- >
- ? TArrayElement extends ObjectSchemaBuilder<
- any,
- any,
- any,
- any,
- any
- >
- ? PropertyDescriptor<
- TRootSchema,
- TProperties[K],
- PropertyDescriptor<
- TRootSchema,
- TSchema,
- TParentPropertyDescriptor
- >,
- K & string
+ : TProperties[K] extends ArraySchemaBuilder<
+ infer TArrayElement,
+ any,
+ any
+ >
+ ? TArrayElement extends ObjectSchemaBuilder<
+ any,
+ any,
+ any,
+ any,
+ any
>
+ ? PropertyDescriptor<
+ TRootSchema,
+ TProperties[K],
+ PropertyDescriptor<
+ TRootSchema,
+ TSchema,
+ TParentPropertyDescriptor
+ >,
+ K & string
+ >
+ : InferType extends TAssignableTo
+ ? PropertyDescriptor<
+ TRootSchema,
+ TProperties[K],
+ PropertyDescriptor<
+ TRootSchema,
+ TSchema,
+ TParentPropertyDescriptor
+ >,
+ K & string
+ >
+ : never
: InferType extends TAssignableTo
? PropertyDescriptor<
TRootSchema,
@@ -620,20 +641,8 @@ export type PropertyDescriptorTree<
>,
K & string
>
- : never
- : InferType extends TAssignableTo
- ? PropertyDescriptor<
- TRootSchema,
- TProperties[K],
- PropertyDescriptor<
- TRootSchema,
- TSchema,
- TParentPropertyDescriptor
- >,
- K & string
- >
- : never;
- }
+ : never;
+ }
: never);
/**
@@ -742,6 +751,8 @@ type ResolvedSchemaType<
* **Note:** this class is not intended to be used directly, use one of the subclasses instead.
* @typeparam TResult Type of the object that will be returned by `validate()` method.
* @typeparam TRequired If `true`, object will be required. If `false`, object will be optional.
+ * @typeParam TInput - Declared input before decoding. Defaults to TResult so
+ * existing generic arguments retain their meaning.
*/
export abstract class SchemaBuilder<
TResult = any,
@@ -749,7 +760,11 @@ export abstract class SchemaBuilder<
TNullable extends boolean = false,
THasDefault extends boolean = false,
// biome-ignore lint/correctness/noUnusedVariables: used in extensions
- TExtensions = {}
+ TExtensions = {},
+ TInput = TResult,
+ TResolvedInput =
+ | ResolvedSchemaType
+ | (THasDefault extends true ? undefined : never)
> {
#isRequired = true;
#isNullable = false;
@@ -777,6 +792,7 @@ export abstract class SchemaBuilder<
*/
#standardProps:
| StandardSchemaV1.Props<
+ TResolvedInput,
ResolvedSchemaType
>
| undefined;
@@ -799,6 +815,9 @@ export abstract class SchemaBuilder<
*/
declare readonly [__hasDefault]: THasDefault;
+ /** @internal Input type, including omission when a default exists. */
+ declare readonly [__input]: TResolvedInput;
+
/**
* Standard Schema v1 interface.
*
@@ -807,13 +826,13 @@ export abstract class SchemaBuilder<
* consumes the spec — including tRPC, TanStack Form, React Hook Form, T3 Env,
* Hono, Elysia, next-safe-action, and 50+ other tools.
*
- * Every `SchemaBuilder` subclass (all 13 builders) inherits this property
+ * Every `SchemaBuilder` subclass inherits this property
* automatically — no additional setup required.
*
* **Shape of the returned object:**
* - `version` — always `1` (Standard Schema spec version)
* - `vendor` — `'@cleverbrush/schema'`
- * - `validate(value)` — synchronous; wraps this builder's own `.validate()`
+ * - `validate(value)` — asynchronous; wraps this builder's `.validateAsync()`
* and converts its result to the Standard Schema `Result
+
+
Named references and input/output views
+
+ Use schemaRef to annotate one use of a named definition
+ without cloning it. Requests use input views and
+ responses use output views; differing named
+ representations receive collision-checked Input and
+ Output suffixes.
+
+
+ Schema boundary composition and compatibility
+
+
{/* ── Installation ─────────────────────────────────── */}
= {
'parse-string': ParseStringSection,
'object-constructors': ObjectConstructorsSection,
'schema-modifiers': SchemaModifiersSection,
+ 'schema-boundaries': SchemaBoundariesSection,
extensions: ExtensionsSection,
'built-in-extensions': BuiltInExtensionsSection,
'generic-schemas': GenericSchemasSection,
diff --git a/websites/schema/app/docs/sections/index.ts b/websites/schema/app/docs/sections/index.ts
index 79e3d249..a86e09a5 100644
--- a/websites/schema/app/docs/sections/index.ts
+++ b/websites/schema/app/docs/sections/index.ts
@@ -19,6 +19,7 @@ export const SECTION_GROUPS = [
label: 'Composition',
slugs: [
'immutability',
+ 'schema-boundaries',
'discriminated-unions',
'recursive-schemas',
'parse-string',
@@ -50,6 +51,11 @@ export const SECTION_GROUPS = [
];
export const SCHEMA_SECTIONS: SchemaSection[] = [
+ {
+ slug: 'schema-boundaries',
+ title: 'Schema Boundaries',
+ group: 'Composition'
+ },
{ slug: 'why', title: 'Why @cleverbrush/schema?', group: 'Fundamentals' },
{
slug: 'getting-started',
diff --git a/websites/schema/app/docs/sections/schema-boundaries.tsx b/websites/schema/app/docs/sections/schema-boundaries.tsx
new file mode 100644
index 00000000..5a3f8235
--- /dev/null
+++ b/websites/schema/app/docs/sections/schema-boundaries.tsx
@@ -0,0 +1,87 @@
+export default function SchemaBoundariesSection() {
+ return (
+ <>
+
+
Schemas Across Boundaries
+
+ Compose named references and explicit input-to-output
+ decoding without duplicating validation rules.
+
+
+
+
Optional fallbacks
+
+ {`const text = string().optional().catch(undefined);
+const nullableText = string().nullable().catch(null);`}
+
+
+ Legacy optional schemas accept null at runtime. A fallback
+ does not replace a value that passed validation. Normalize
+ null explicitly if your application requires undefined; this
+ release does not change that compatibility behavior.
+
+
+
+
One named definition
+
+ {`const User = object({ name: string() }).schemaName('User');
+const History = object({
+ current: schemaRef(User),
+ previous: schemaRef(User).nullable().optional()
+ .describe('Previous user')
+});`}
+
+
+ Reference modifiers apply at the use site. The original
+ named definition remains unchanged, and genuinely
+ conflicting definitions still fail registration.
+
+
+
+
Declared input and output
+
+ {`const PageSize = decode(
+ string(),
+ number().isInteger().min(1).max(100),
+ value => Number(value)
+);
+type Editable = InferInput; // string
+type Validated = InferOutput; // number
+PageSize.parse('20'); // 20`}
+
+
+ The input is validated before conversion; the converted
+ result is validated against the output schema. Converter
+ errors become validation failures. Use parseAsync for
+ asynchronous converters. InferType remains an output alias.
+
+
+ Nested schemas and Standard Schema retain both types.
+ Defaults on the input schema run before conversion; a
+ default on the boundary is an output default.
+
+
+
+
Export and adoption
+
+ {`toJsonSchema(PageSize, { mode: 'input' });
+toJsonSchema(PageSize, { mode: 'output' }); // default`}
+
+
+ OpenAPI uses input for requests and output for responses,
+ splitting named components when their shapes differ. Export
+ describes declared shapes, not arbitrary conversion logic.
+
+
+ Form state and typed-client request inference are separate
+ integrations. Until adopted there, keep explicit wire
+ contracts and decode at your application boundary. No
+ reverse conversion is inferred.
+
+
+ Read the full composition guide
+
+
+ >
+ );
+}
diff --git a/websites/schema/app/schema-json/page.tsx b/websites/schema/app/schema-json/page.tsx
index 4f3120a4..23926606 100644
--- a/websites/schema/app/schema-json/page.tsx
+++ b/websites/schema/app/schema-json/page.tsx
@@ -32,6 +32,19 @@ export default function SchemaJsonPage() {
]}
/>
+
+
Input and output views
+
+ Pass mode: 'input' to describe values before
+ decoding and defaults, or mode: 'output' for
+ validated values (the default). Standard JSON Schema
+ input/output converters use the same views without
+ executing conversions.
+
+
+ Named references, decoding, and compatibility
+
+
{/* ── Installation ─────────────────────────────────── */}
Date: Mon, 28 Sep 2026 20:26:26 +0000
Subject: [PATCH 2/5] fix(schema-json): honor final boundary output presence
---
libs/schema-json/src/boundaries.test.ts | 15 +++++++++++++++
libs/schema-json/src/toJsonSchema.ts | 11 ++++++-----
2 files changed, 21 insertions(+), 5 deletions(-)
diff --git a/libs/schema-json/src/boundaries.test.ts b/libs/schema-json/src/boundaries.test.ts
index 404978f9..f0fbf6ac 100644
--- a/libs/schema-json/src/boundaries.test.ts
+++ b/libs/schema-json/src/boundaries.test.ts
@@ -58,6 +58,21 @@ describe('boundary JSON Schema views', () => {
expect(toJsonSchema(schema).required).toEqual(['size', 'title']);
});
+ it('respects output presence when defaults follow optional modifiers', () => {
+ const size = decode(string(), number(), Number).optional().default(5);
+ const name = schemaRef(string().schemaName('Name'))
+ .optional()
+ .default('untitled');
+ const schema = object({ size, name });
+ expect(schema.parse({})).toEqual({ size: 5, name: 'untitled' });
+ expect(
+ toJsonSchema(schema, { mode: 'input' }).required
+ ).toBeUndefined();
+ expect(toJsonSchema(schema).required).toEqual(['size', 'name']);
+ const optionalOutput = object({ size: size.optional() });
+ expect(toJsonSchema(optionalOutput).required).toBeUndefined();
+ });
+
it.each([
'2020-12',
'07'
diff --git a/libs/schema-json/src/toJsonSchema.ts b/libs/schema-json/src/toJsonSchema.ts
index fdb460b8..fad5f28c 100644
--- a/libs/schema-json/src/toJsonSchema.ts
+++ b/libs/schema-json/src/toJsonSchema.ts
@@ -17,13 +17,14 @@ function isRequiredInMode(
mode: 'input' | 'output'
): boolean {
const info = schema.introspect() as any;
- if (mode === 'input' && info.hasDefault) return false;
+ // Output presence follows the wrapper's final modifiers. In particular,
+ // optional().default(...) produces a required output despite the earlier
+ // explicit input-omission modifier.
+ if (mode === 'output') return info.isRequired !== false;
+ if (info.hasDefault) return false;
if (info.type === 'decode' || info.type === 'reference') {
if (info.presence !== undefined) return info.presence;
- return isRequiredInMode(
- mode === 'input' ? info.inputSchema : info.outputSchema,
- mode
- );
+ return isRequiredInMode(info.inputSchema, mode);
}
return info.isRequired !== false;
}
From 1f0b254ceadeafe8619f3099c007d8f1dbf67e42 Mon Sep 17 00:00:00 2001
From: Andrew Zolotukhin
Date: Mon, 28 Sep 2026 21:43:15 +0000
Subject: [PATCH 3/5] refactor(schema): drop explicit input and output support
from PR
---
.changeset/schema-boundaries.md | 10 +-
libs/knex-schema/src/entity.ts | 32 +-
libs/schema-json/README.md | 8 +-
libs/schema-json/src/boundaries.test.ts | 115 +---
libs/schema-json/src/standardJsonSchema.ts | 25 +-
libs/schema-json/src/toJsonSchema.ts | 86 +--
libs/schema-json/src/types.ts | 2 -
libs/schema/BOUNDARIES.md | 98 +---
libs/schema/README.md | 9 +-
libs/schema/src/boundaries-docs.test.ts | 8 +-
.../schema/src/builders/ArraySchemaBuilder.ts | 8 +-
.../builders/BoundarySchemaBuilder.test.ts | 167 ------
.../src/builders/BoundarySchemaBuilder.ts | 528 ------------------
.../src/builders/ExternSchemaBuilder.ts | 3 +-
.../src/builders/IntersectionSchemaBuilder.ts | 4 +-
libs/schema/src/builders/LazySchemaBuilder.ts | 48 +-
.../src/builders/ObjectSchemaBuilder.ts | 41 +-
.../src/builders/RecordSchemaBuilder.ts | 4 +-
.../builders/ReferenceSchemaBuilder.test.ts | 149 +++++
.../src/builders/ReferenceSchemaBuilder.ts | 387 +++++++++++++
libs/schema/src/builders/SchemaBuilder.ts | 31 +-
.../schema/src/builders/TupleSchemaBuilder.ts | 9 +-
.../schema/src/builders/UnionSchemaBuilder.ts | 4 +-
libs/schema/src/builders/boundaries.test-d.ts | 102 ----
libs/schema/src/builders/references.test-d.ts | 47 ++
libs/schema/src/core.ts | 11 +-
libs/server-openapi/README.md | 9 +-
libs/server-openapi/src/boundaries.test.ts | 140 +++--
.../src/generateAsyncApiSpec.ts | 22 +-
.../server-openapi/src/generateOpenApiSpec.ts | 41 +-
libs/server-openapi/src/schemaConverter.ts | 9 +-
libs/server-openapi/src/schemaRegistry.ts | 98 +---
websites/docs/app/server-openapi/page.tsx | 10 +-
.../app/docs/sections/schema-boundaries.tsx | 48 +-
websites/schema/app/schema-json/page.tsx | 13 +-
35 files changed, 866 insertions(+), 1460 deletions(-)
delete mode 100644 libs/schema/src/builders/BoundarySchemaBuilder.test.ts
delete mode 100644 libs/schema/src/builders/BoundarySchemaBuilder.ts
create mode 100644 libs/schema/src/builders/ReferenceSchemaBuilder.test.ts
create mode 100644 libs/schema/src/builders/ReferenceSchemaBuilder.ts
delete mode 100644 libs/schema/src/builders/boundaries.test-d.ts
create mode 100644 libs/schema/src/builders/references.test-d.ts
diff --git a/.changeset/schema-boundaries.md b/.changeset/schema-boundaries.md
index e5c45af5..4694b904 100644
--- a/.changeset/schema-boundaries.md
+++ b/.changeset/schema-boundaries.md
@@ -2,11 +2,9 @@
"@cleverbrush/schema": minor
"@cleverbrush/schema-json": minor
"@cleverbrush/server-openapi": minor
-"@cleverbrush/knex-schema": minor
---
-Add optional-aware fallbacks and preprocessing, immutable named schema references,
-and typed input/output decoding with nested inference and Standard Schema support.
-Generate directional JSON Schema/OpenAPI components with strict name collision
-checks. Preserve existing InferType output semantics and legacy optional null
-acceptance. Keep ORM navigation inference compatible with the richer schema types.
+Add optional-aware fallbacks and preprocessing, and immutable named schema
+references with local annotations. Preserve one canonical definition in JSON
+Schema, OpenAPI and AsyncAPI with strict name collision checks. Keep existing
+type inference and legacy optional null acceptance unchanged.
diff --git a/libs/knex-schema/src/entity.ts b/libs/knex-schema/src/entity.ts
index 4fbb5254..93ff5896 100644
--- a/libs/knex-schema/src/entity.ts
+++ b/libs/knex-schema/src/entity.ts
@@ -9,6 +9,7 @@
// extension (compatible with the existing query/include implementation).
import {
+ type ArraySchemaBuilder,
type InferType,
ObjectSchemaBuilder,
type PropertyDescriptorTree,
@@ -128,27 +129,18 @@ export type EntityPropSelector<
*
* @public
*/
-export type UnwrapNavSchema = TProp extends {
- introspect(): { elementSchema: infer TEl };
-}
- ? NonNullable extends ObjectSchemaBuilder<
- any,
- any,
- any,
- any,
- any,
- any,
- any
- >
- ? NonNullable
- : never
- : TProp extends ObjectSchemaBuilder
- ? TProp
- : TProp extends SchemaBuilder
- ? T extends ObjectSchemaBuilder
- ? T
+export type UnwrapNavSchema =
+ TProp extends ArraySchemaBuilder
+ ? TEl extends ObjectSchemaBuilder
+ ? TEl
: never
- : never;
+ : TProp extends ObjectSchemaBuilder
+ ? TProp
+ : TProp extends SchemaBuilder
+ ? T extends ObjectSchemaBuilder
+ ? T
+ : never
+ : never;
/**
* Merge type for a single polymorphic variant branch: variant schema fields
diff --git a/libs/schema-json/README.md b/libs/schema-json/README.md
index dacf6292..e6d64655 100644
--- a/libs/schema-json/README.md
+++ b/libs/schema-json/README.md
@@ -12,13 +12,7 @@ for use in OpenAPI specs, form generators, or any other JSON Schema consumer.
## When to use this library
-### Input and output views
-
-`toJsonSchema(schema, { mode: 'input' })` describes input before decoding/defaults;
-`mode: 'output'` (the default) describes validated output.
-`withStandardJsonSchema(schema)` exposes the same distinction through
-`jsonSchema.input()` and `jsonSchema.output()`. Converters are never executed.
-Their arbitrary logic is not representable in JSON Schema.
+### Named references
Named reference wrappers preserve use-site annotations, optionality and nullability
without changing the shared definition. See the [composition guide](../schema/BOUNDARIES.md).
diff --git a/libs/schema-json/src/boundaries.test.ts b/libs/schema-json/src/boundaries.test.ts
index f0fbf6ac..ea57988e 100644
--- a/libs/schema-json/src/boundaries.test.ts
+++ b/libs/schema-json/src/boundaries.test.ts
@@ -1,82 +1,13 @@
-import {
- array,
- decode,
- lazy,
- number,
- object,
- record,
- schemaRef,
- string,
- tuple,
- union
-} from '@cleverbrush/schema';
-import { describe, expect, it, vi } from 'vitest';
+import { object, schemaRef, string } from '@cleverbrush/schema';
+import { describe, expect, it } from 'vitest';
import { withStandardJsonSchema } from './standardJsonSchema.js';
import { toJsonSchema } from './toJsonSchema.js';
-describe('boundary JSON Schema views', () => {
- it('does not transfer output nullability or output defaults to input', () => {
- const schema = decode(string(), number().nullable(), () => null);
- expect(toJsonSchema(schema, { $schema: false, mode: 'input' })).toEqual(
- { allOf: [{ type: 'string' }] }
- );
- expect(toJsonSchema(schema, { $schema: false }).allOf).toEqual([
- { type: ['integer', 'null'] }
- ]);
- const withDefault = decode(string(), number(), Number).default(3);
- expect(
- toJsonSchema(withDefault, { mode: 'input' }).default
- ).toBeUndefined();
- expect(toJsonSchema(withDefault).default).toBe(3);
- });
- it('exports declared input/output without running converters', () => {
- const converter = vi.fn(Number);
- const size = decode(
- string().minLength(1),
- number().isInteger().min(1),
- converter
- );
- expect(toJsonSchema(size, { $schema: false, mode: 'input' })).toEqual({
- allOf: [{ type: 'string', minLength: 1 }]
- });
- expect(toJsonSchema(size, { $schema: false })).toEqual({
- allOf: [{ type: 'integer', minimum: 1 }]
- });
- const standard = withStandardJsonSchema(size)['~standard'].jsonSchema;
- expect(standard.input({ target: 'draft-2020-12' })).not.toEqual(
- standard.output({ target: 'draft-2020-12' })
- );
- expect(converter).not.toHaveBeenCalled();
- });
-
- it('tracks input defaults separately from required output', () => {
- const size = decode(string().default('1'), number(), Number);
- const schema = object({ size, title: string().default('new') });
- expect(
- toJsonSchema(schema, { mode: 'input' }).required
- ).toBeUndefined();
- expect(toJsonSchema(schema).required).toEqual(['size', 'title']);
- });
-
- it('respects output presence when defaults follow optional modifiers', () => {
- const size = decode(string(), number(), Number).optional().default(5);
- const name = schemaRef(string().schemaName('Name'))
- .optional()
- .default('untitled');
- const schema = object({ size, name });
- expect(schema.parse({})).toEqual({ size: 5, name: 'untitled' });
- expect(
- toJsonSchema(schema, { mode: 'input' }).required
- ).toBeUndefined();
- expect(toJsonSchema(schema).required).toEqual(['size', 'name']);
- const optionalOutput = object({ size: size.optional() });
- expect(toJsonSchema(optionalOutput).required).toBeUndefined();
- });
-
+describe('named reference JSON Schema', () => {
it.each([
'2020-12',
'07'
- ] as const)('keeps ref annotations outside the definition in draft %s', draft => {
+ ] as const)('keeps annotations outside the definition in draft %s', draft => {
const user = object({ name: string() }).schemaName('User');
const schema = object({
user: schemaRef(user),
@@ -106,18 +37,30 @@ describe('boundary JSON Schema views', () => {
expect(user.introspect().description).toBeUndefined();
});
- it('projects nested containers and lazy wrappers', () => {
- const size = decode(string(), number(), Number);
- const schema = object({
- list: array(size),
- pair: tuple([size]),
- values: record(string(), size),
- option: union(size).or(string()),
- later: lazy(() => size)
- });
- const input = JSON.stringify(toJsonSchema(schema, { mode: 'input' }));
- const output = JSON.stringify(toJsonSchema(schema));
- expect(input).not.toContain('"type":"integer"');
- expect(output).toContain('"type":"integer"');
+ it('keeps final local default and nullability modifiers', () => {
+ const target = string().nullable().schemaName('Name');
+ const ref = schemaRef(target).notNullable().optional().default('new');
+ const schema = object({ name: ref });
+ const json = toJsonSchema(schema) as any;
+ expect(schema.parse({})).toEqual({ name: 'new' });
+ expect(json.required).toEqual(['name']);
+ expect(json.properties.name.default).toBe('new');
+ expect(json.properties.name.allOf).toEqual([
+ { type: ['string', 'null'] },
+ { not: { type: 'null' } }
+ ]);
+ const nullableAgain = toJsonSchema(ref.nullable());
+ expect(nullableAgain.anyOf).toBeDefined();
+ expect(toJsonSchema(ref.readonly()).readOnly).toBe(true);
+ });
+
+ it('retains identical Standard JSON Schema views', () => {
+ const ref = schemaRef(string().schemaName('Name'))
+ .optional()
+ .describe('A name');
+ const standard = withStandardJsonSchema(ref)['~standard'].jsonSchema;
+ expect(standard.input({ target: 'draft-2020-12' })).toEqual(
+ standard.output({ target: 'draft-2020-12' })
+ );
});
});
diff --git a/libs/schema-json/src/standardJsonSchema.ts b/libs/schema-json/src/standardJsonSchema.ts
index fb3ddc74..b5e41e18 100644
--- a/libs/schema-json/src/standardJsonSchema.ts
+++ b/libs/schema-json/src/standardJsonSchema.ts
@@ -1,8 +1,4 @@
-import type {
- InferInput,
- InferOutput,
- SchemaBuilder
-} from '@cleverbrush/schema';
+import type { SchemaBuilder } from '@cleverbrush/schema';
import type { StandardJSONSchemaV1 } from '@standard-schema/spec';
import { toJsonSchema } from './toJsonSchema.js';
@@ -35,8 +31,9 @@ function targetToOptions(target: StandardJSONSchemaV1.Target): {
* [Standard JSON Schema v1](https://standardschema.dev/) interface.
*
* The returned schema object's `~standard` property is enriched with a
- * `jsonSchema` converter. Input describes values before decoding/defaults;
- * output describes validated values. Converter functions are never executed.
+ * `jsonSchema` converter. Because `@cleverbrush/schema` does not
+ * distinguish between input and output types, both `input()` and `output()`
+ * produce the same JSON Schema document.
*
* **Note:** this mutates the schema instance by overriding the `~standard`
* property. The returned reference is the same schema object.
@@ -59,19 +56,13 @@ function targetToOptions(target: StandardJSONSchemaV1.Target): {
*/
export function withStandardJsonSchema<
T extends SchemaBuilder
->(schema: T): T & StandardJSONSchemaV1, InferOutput> {
+>(schema: T): T & StandardJSONSchemaV1 {
const converter: StandardJSONSchemaV1.Converter = {
input(options: StandardJSONSchemaV1.Options): Record {
- return toJsonSchema(schema, {
- ...targetToOptions(options.target),
- mode: 'input'
- });
+ return toJsonSchema(schema, targetToOptions(options.target));
},
output(options: StandardJSONSchemaV1.Options): Record {
- return toJsonSchema(schema, {
- ...targetToOptions(options.target),
- mode: 'output'
- });
+ return toJsonSchema(schema, targetToOptions(options.target));
}
};
@@ -94,5 +85,5 @@ export function withStandardJsonSchema<
enumerable: false
});
- return schema as T & StandardJSONSchemaV1, InferOutput>;
+ return schema as T & StandardJSONSchemaV1;
}
diff --git a/libs/schema-json/src/toJsonSchema.ts b/libs/schema-json/src/toJsonSchema.ts
index fad5f28c..b5bbccaf 100644
--- a/libs/schema-json/src/toJsonSchema.ts
+++ b/libs/schema-json/src/toJsonSchema.ts
@@ -11,57 +11,28 @@ function escapeJsonPointerSegment(s: string): string {
return s.replace(/~/g, '~0').replace(/\//g, '~1');
}
-/** Whether a property must be present on the selected side of a boundary. */
-function isRequiredInMode(
- schema: SchemaBuilder,
- mode: 'input' | 'output'
-): boolean {
- const info = schema.introspect() as any;
- // Output presence follows the wrapper's final modifiers. In particular,
- // optional().default(...) produces a required output despite the earlier
- // explicit input-omission modifier.
- if (mode === 'output') return info.isRequired !== false;
- if (info.hasDefault) return false;
- if (info.type === 'decode' || info.type === 'reference') {
- if (info.presence !== undefined) return info.presence;
- return isRequiredInMode(info.inputSchema, mode);
- }
- return info.isRequired !== false;
-}
-
type Resolver =
| ((schema: SchemaBuilder) => string | null)
| undefined;
function convertNodeInner(
schema: SchemaBuilder,
- resolver: Resolver,
- mode: 'input' | 'output'
+ resolver: Resolver
): Out {
const info = schema.introspect() as any;
const ext: Record = info.extensions ?? {};
const readOnly: Out = info.isReadonly === true ? { readOnly: true } : {};
switch (info.type) {
- case 'reference':
- case 'decode': {
- const target =
- mode === 'input' ? info.inputSchema : info.outputSchema;
- const out: Out = { allOf: [convertNode(target, resolver, mode)] };
+ case 'reference': {
+ const out: Out = {
+ ...readOnly,
+ allOf: [convertNode(info.targetSchema, resolver)]
+ };
if (info.nullability === false)
(out.allOf as Out[]).push({ not: { type: 'null' } });
return out;
}
- case 'record':
- return {
- type: 'object',
- propertyNames: convertNode(info.keySchema, resolver, mode),
- additionalProperties: convertNode(
- info.valueSchema,
- resolver,
- mode
- )
- };
case 'string': {
if (info.equalsTo !== undefined)
return { ...readOnly, const: info.equalsTo };
@@ -133,7 +104,7 @@ function convertNodeInner(
case 'array': {
const out: Out = { ...readOnly, type: 'array' };
if (info.elementSchema)
- out['items'] = convertNode(info.elementSchema, resolver, mode);
+ out['items'] = convertNode(info.elementSchema, resolver);
if (info.minLength !== undefined) out['minItems'] = info.minLength;
if (info.maxLength !== undefined) out['maxItems'] = info.maxLength;
if (ext['nonempty'] === true && out['minItems'] === undefined)
@@ -146,11 +117,11 @@ function convertNodeInner(
info.elements ?? [];
const out: Out = {
type: 'array',
- prefixItems: elements.map(e => convertNode(e, resolver, mode)),
+ prefixItems: elements.map(e => convertNode(e, resolver)),
minItems: elements.length
};
if (info.restSchema) {
- out['items'] = convertNode(info.restSchema, resolver, mode);
+ out['items'] = convertNode(info.restSchema, resolver);
} else {
out['items'] = false;
out['maxItems'] = elements.length;
@@ -167,8 +138,9 @@ function convertNodeInner(
const outProps: Record = {};
const required: string[] = [];
for (const [key, propSchema] of Object.entries(props)) {
- outProps[key] = convertNode(propSchema, resolver, mode);
- if (isRequiredInMode(propSchema, mode)) required.push(key);
+ outProps[key] = convertNode(propSchema, resolver);
+ if ((propSchema.introspect() as any).isRequired !== false)
+ required.push(key);
}
out['properties'] = outProps;
if (required.length > 0) out['required'] = required;
@@ -201,7 +173,7 @@ function convertNodeInner(
}
if (allConst) return { ...readOnly, enum: enumValues };
- const converted = options.map(o => convertNode(o, resolver, mode));
+ const converted = options.map(o => convertNode(o, resolver));
const out: Out = {
...readOnly,
anyOf: converted
@@ -254,8 +226,8 @@ function convertNodeInner(
return {
...readOnly,
allOf: [
- convertNode(left, resolver, mode),
- convertNode(right, resolver, mode)
+ convertNode(left, resolver),
+ convertNode(right, resolver)
]
};
}
@@ -267,7 +239,7 @@ function convertNodeInner(
// Recursive schemas without a registered name will cause infinite
// recursion here; callers must use .schemaName() to break the cycle.
const resolved: SchemaBuilder = info.getter();
- return convertNode(resolved, resolver, mode);
+ return convertNode(resolved, resolver);
}
default:
@@ -277,8 +249,7 @@ function convertNodeInner(
function convertNode(
schema: SchemaBuilder,
- resolver: Resolver,
- mode: 'input' | 'output'
+ resolver: Resolver
): Out {
if (resolver) {
const name = resolver(schema);
@@ -288,7 +259,7 @@ function convertNode(
};
}
}
- const out = convertNodeInner(schema, resolver, mode);
+ const out = convertNodeInner(schema, resolver);
const info = schema.introspect() as any;
if (typeof info.description === 'string' && info.description !== '')
out['description'] = info.description;
@@ -301,10 +272,6 @@ function convertNode(
// Emit default for serializable primitives (not factory functions)
if (
info.hasDefault === true &&
- !(
- mode === 'input' &&
- (info.type === 'decode' || info.type === 'reference')
- ) &&
info.defaultValue !== undefined &&
typeof info.defaultValue !== 'function'
) {
@@ -313,7 +280,7 @@ function convertNode(
// Handle nullable — JSON Schema 2020-12 style: type becomes an array
if (
- info.type === 'reference' || info.type === 'decode'
+ info.type === 'reference'
? info.nullability === true
: info.isNullable === true
) {
@@ -324,11 +291,10 @@ function convertNode(
if (!hasNull) anyOf.push({ type: 'null' });
} else if (out['allOf'] !== undefined && out['type'] === undefined) {
// Intersection type without a top-level type — wrap in oneOf with null
- out[
- info.type === 'reference' || info.type === 'decode'
- ? 'anyOf'
- : 'oneOf'
- ] = [{ allOf: out['allOf'] as Out[] }, { type: 'null' }];
+ out[info.type === 'reference' ? 'anyOf' : 'oneOf'] = [
+ { allOf: out['allOf'] as Out[] },
+ { type: 'null' }
+ ];
delete out['allOf'];
} else if (out['enum'] !== undefined) {
// Enum — add null to enum values if not already present
@@ -412,11 +378,7 @@ export function toJsonSchema(
schema: SchemaBuilder,
opts?: ToJsonSchemaOptions
): Record {
- const body = convertNode(
- schema,
- opts?.nameResolver,
- opts?.mode ?? 'output'
- );
+ const body = convertNode(schema, opts?.nameResolver);
if (opts?.$schema === false) return body;
const draft = opts?.draft ?? '2020-12';
const uri =
diff --git a/libs/schema-json/src/types.ts b/libs/schema-json/src/types.ts
index afb1f950..089b9748 100644
--- a/libs/schema-json/src/types.ts
+++ b/libs/schema-json/src/types.ts
@@ -137,8 +137,6 @@ export type InferFromJsonSchema = S extends { readonly const: infer V }
/** Options accepted by {@link toJsonSchema}. */
export type ToJsonSchemaOptions = {
- /** Declared input before decoding/defaults, or validated output. @default 'output' */
- mode?: 'input' | 'output';
/**
* JSON Schema draft version to reference in the `$schema` header.
* @default '2020-12'
diff --git a/libs/schema/BOUNDARIES.md b/libs/schema/BOUNDARIES.md
index 589b6544..0fe12617 100644
--- a/libs/schema/BOUNDARIES.md
+++ b/libs/schema/BOUNDARIES.md
@@ -37,8 +37,8 @@ const normalizedText = string().optional()
Preprocessors can return optional/nullable values, including asynchronously.
Their existing callback parameter typing is preserved; it is not a guarantee
that unknown runtime input already has that type. Use explicit guards or
-`decode` at untrusted boundaries. Global strict null rejection is a separate,
-compatibility-sensitive follow-up.
+preprocessing/conversion followed by validation at untrusted boundaries.
+Global strict null rejection is a separate, compatibility-sensitive follow-up.
## One named definition, many annotated references
@@ -65,87 +65,21 @@ nullability controls null acceptance for the wrapper independently.
JSON Schema/OpenAPI keeps one definition and uses reference composition for
local annotations, examples and nullability, including Draft 07 references.
-## Declare both sides of a conversion
+## Defaults and validation
-```ts
-import { decode, type InferInput, type InferOutput, number, object, string }
- from '@cleverbrush/schema';
-
-const PageSize = decode(
- string(),
- number().isInteger().min(1).max(100),
- value => Number(value)
-);
-const SearchInput = object({
- pageSize: PageSize,
- term: string().default('')
-});
-
-type Editable = InferInput; // pageSize is string
-type Validated = InferOutput; // pageSize is number
-const request = SearchInput.parse({ pageSize: '20' });
-```
-
-Parsing validates the input, invokes the converter once, then validates its
-output. Converter exceptions/rejections become validation failures. Asynchronous
-converters require `parseAsync`/`validateAsync`; synchronous parsing rejects
-Promise-returning conversion. Runtime parsing always validates unknown values.
-
-Nested objects, arrays, tuples, records, unions, intersections, references and
-lazy schemas retain input/output inference. Recursive schemas still require
-explicit TypeScript annotations. Standard Schema exposes the corresponding
-input/output types. `InferOutput` is an alias for `InferType`.
-
-An input-schema default runs before conversion. A default on the boundary itself
-is an **output** default, validated without conversion. Optional/nullable
-use-site modifiers bypass conversion for their allowed sentinel values.
-
-### Application-agnostic example
-
-```ts
-const Priority = decode(
- string().oneOf('low', 'normal', 'high'),
- number().min(1).max(3),
- value => ({ low: 1, normal: 2, high: 3 })[value]
-);
-const Ticket = object({ title: string().minLength(1), priority: Priority });
-Ticket.parse({ title: 'Improve documentation', priority: 'high' });
-// { title: 'Improve documentation', priority: 3 }
-```
-
-Conversion policy remains application-owned: trimming, empty strings, numeric
-precision, date formats and intentional data loss are not global defaults.
-
-## JSON Schema and OpenAPI
-
-```ts
-import { toJsonSchema, withStandardJsonSchema } from '@cleverbrush/schema-json';
-
-toJsonSchema(PageSize, { mode: 'input' }); // declared string shape
-toJsonSchema(PageSize, { mode: 'output' }); // constrained numeric shape
-toJsonSchema(PageSize); // output remains the default
-
-const standard = withStandardJsonSchema(PageSize)['~standard'];
-standard.jsonSchema.input({ target: 'draft-2020-12' });
-standard.jsonSchema.output({ target: 'draft-2020-12' });
-```
-
-Converters are never executed by schema export. JSON Schema describes the
-declared shapes, not arbitrary converter logic or opaque custom validators.
-
-OpenAPI uses input views for requests and output views for responses. Named
-schemas with different views receive `NameInput` and `NameOutput` components;
-unchanged views retain their original name. Generated-name collisions throw,
-including collisions propagated through nested named or recursive references.
-AsyncAPI incoming/outgoing payloads use the same directional model.
+A default on the reference overrides missing values at that use site and is
+validated by the target. Clearing it leaves any target default intact. Local
+fallbacks use the existing catch semantics. Async target validators and local
+callbacks require `parseAsync`/`validateAsync`.
-## Adoption boundary
+Use `InferType` for schema inference and the existing `hasType` method when an
+explicit static override is needed. Static overrides and casts do not change
+runtime validation or convert values.
-This release does **not** redesign React form state or typed HTTP client request
-inference. Those consumers may still assume input equals output. Do not treat a
-decoder as a drop-in shared form/endpoint contract for those APIs.
+## API documents
-Until that adoption, keep an explicit wire schema in shared contracts and decode
-inside the application boundary/handler, then return the validated output DTO.
-A decoder is not an encoder: no reverse conversion is inferred for requests,
-URLs, response serialization, database writes or editable form state.
+JSON Schema, OpenAPI and AsyncAPI retain one canonical component per named
+target. Reference annotations compose around that definition rather than
+changing it. Named recursive references are supported; independently rebuilt
+schemas with the same name still conflict. Standard JSON Schema `input()` and
+`output()` retain their existing identical representation.
diff --git a/libs/schema/README.md b/libs/schema/README.md
index f925db2a..d602cdab 100644
--- a/libs/schema/README.md
+++ b/libs/schema/README.md
@@ -2,12 +2,9 @@
## Schemas across boundaries
-Use optional-aware `catch(undefined)`, `schemaRef(namedSchema)` for per-use
-annotations, and `decode(inputSchema, outputSchema, converter)` for explicit
-conversion. `InferInput` describes editable input; `InferOutput` and the existing
-`InferType` describe validated output. See the [boundary composition guide](./BOUNDARIES.md)
-for examples, null-compatibility limitations, and the separate form/client
-adoption boundary.
+Use optional-aware `catch(undefined)` and `schemaRef(namedSchema)` for per-use
+annotations without cloning named definitions. See the
+[composition guide](./BOUNDARIES.md) for examples and null-compatibility limits.
[](https://github.com/cleverbrush/framework/actions/workflows/ci.yml)
[](https://standardschema.dev/)
diff --git a/libs/schema/src/boundaries-docs.test.ts b/libs/schema/src/boundaries-docs.test.ts
index be8378e4..d023d40f 100644
--- a/libs/schema/src/boundaries-docs.test.ts
+++ b/libs/schema/src/boundaries-docs.test.ts
@@ -7,12 +7,8 @@ describe('published boundary documentation', () => {
const missing: string[] = [];
for (const [path, names] of [
[
- '../dist/builders/BoundarySchemaBuilder.d.ts',
- ['BoundarySchemaBuilder', 'schemaRef', 'decode']
- ],
- [
- '../dist/builders/SchemaBuilder.d.ts',
- ['InferInput', 'InferOutput']
+ '../dist/builders/ReferenceSchemaBuilder.d.ts',
+ ['ReferenceSchemaBuilder', 'schemaRef']
],
['../../schema-json/dist/types.d.ts', ['ToJsonSchemaOptions']]
] as const) {
diff --git a/libs/schema/src/builders/ArraySchemaBuilder.ts b/libs/schema/src/builders/ArraySchemaBuilder.ts
index bcbd575f..a044538b 100644
--- a/libs/schema/src/builders/ArraySchemaBuilder.ts
+++ b/libs/schema/src/builders/ArraySchemaBuilder.ts
@@ -5,7 +5,6 @@ import type {
import {
type BRAND,
createHybridErrorArray,
- type InferInput,
type InferType,
type NestedValidationResult,
type PropertyDescriptor,
@@ -102,8 +101,8 @@ export class ArraySchemaBuilder<
TResult = TExplicitType extends undefined
? TElementSchema extends undefined
? Array
- : TElementSchema extends SchemaBuilder
- ? Array>
+ : TElementSchema extends SchemaBuilder
+ ? Array>>
: never
: TExplicitType
> extends SchemaBuilder<
@@ -111,8 +110,7 @@ export class ArraySchemaBuilder<
TRequired,
TNullable,
THasDefault,
- TExtensions,
- Array>
+ TExtensions
> {
#minLength?: number;
#defaultMinLengthErrorMessageProvider: ValidationErrorMessageProvider<
diff --git a/libs/schema/src/builders/BoundarySchemaBuilder.test.ts b/libs/schema/src/builders/BoundarySchemaBuilder.test.ts
deleted file mode 100644
index b78c1b60..00000000
--- a/libs/schema/src/builders/BoundarySchemaBuilder.test.ts
+++ /dev/null
@@ -1,167 +0,0 @@
-import { describe, expect, it, vi } from 'vitest';
-import {
- array,
- boolean,
- decode,
- number,
- object,
- schemaRef,
- string
-} from '../index.js';
-
-describe('schema boundaries', () => {
- it('accepts optional/nullable fallbacks and preserves legacy null acceptance', async () => {
- const text = string().optional().catch(undefined);
- expect(text.parse(42)).toBeUndefined();
- expect(await text.parseAsync(false)).toBeUndefined();
- expect(text.parse(null)).toBeNull();
- expect(string().nullable().catch(null).parse(42)).toBeNull();
- const normalize = string()
- .optional()
- .addPreprocessor(v => (v == null ? undefined : v))
- .catch(undefined);
- expect(normalize.parse(null)).toBeUndefined();
- expect(object({ text }).safeParse(null).valid).toBe(false);
- expect(
- object({
- text,
- flag: boolean()
- .optional()
- .catch(() => undefined)
- }).parse({ text: {}, flag: 1 })
- ).toEqual({ text: undefined, flag: undefined });
- });
-
- it('validates both sides and converts once', () => {
- const convert = vi.fn(Number);
- const size = decode(
- string(),
- number().isInteger().min(1).max(100),
- convert
- );
- expect(size.parse('12')).toBe(12);
- expect(convert).toHaveBeenCalledTimes(1);
- expect(size.safeParse(12).valid).toBe(false);
- expect(convert).toHaveBeenCalledTimes(1);
- expect(size.safeParse('0').valid).toBe(false);
- expect(size.safeParse('nope').valid).toBe(false);
- expect(object({ size }).parse({ size: '5' })).toEqual({ size: 5 });
- expect(array(size).parse(['2', '3'])).toEqual([2, 3]);
- });
-
- it('supports async conversion and captures converter failures', async () => {
- const size = decode(string(), number(), async value => Number(value));
- expect(() => size.parse('1')).toThrow(/parseAsync/);
- expect(await size.parseAsync('2')).toBe(2);
- expect(await size['~standard'].validate('3')).toEqual({ value: 3 });
- const broken = decode(string(), number(), () => {
- throw new Error('not convertible');
- });
- expect(broken.safeParse('x')).toMatchObject({
- valid: false,
- errors: [{ message: 'Decoder failed: not convertible' }]
- });
- const rejected = decode(string(), number(), async () => {
- throw new Error('rejected');
- });
- expect((await rejected.safeParseAsync('x')).valid).toBe(false);
- });
-
- it('handles input defaults, output defaults and use-site modifiers', () => {
- const size = decode(string().default('2'), number(), Number);
- expect(size.parse(undefined)).toBe(2);
- expect(size.optional().parse(undefined)).toBeUndefined();
- expect(size.nullable().parse(null)).toBeNull();
- expect(size.default(4).parse(undefined)).toBe(4);
- expect(size.default(4).clearDefault().parse(undefined)).toBe(2);
- expect(size.optional().required().safeParse(undefined).valid).toBe(
- false
- );
- });
-
- it('preserves one named definition and local metadata', () => {
- const user = object({ name: string().minLength(1) }).schemaName('User');
- const ref = schemaRef(user);
- const previous = ref.nullable().optional().describe('Previous user');
- expect(ref.introspect().outputSchema).toBe(user);
- expect(previous.introspect().outputSchema).toBe(user);
- expect(user.introspect().description).toBeUndefined();
- expect(ref.safeParse(null).valid).toBe(false);
- expect(previous.parse(null)).toBeNull();
- expect(previous.parse(undefined)).toBeUndefined();
- expect(previous.parse({ name: 'Ada' })).toEqual({ name: 'Ada' });
- expect(() => schemaRef(string())).toThrow(/named schema/);
- });
-
- it('preserves nested reference errors and selectors', () => {
- const user = object({ name: string().minLength(1) }).schemaName('User');
- const result = object({ user: schemaRef(user) }).validate({
- user: { name: '' }
- });
- expect(result.valid).toBe(false);
- expect(
- result.getErrorsFor(t => t.user.name).errors.length
- ).toBeGreaterThan(0);
- expect(
- result.getInvalidProperties().map(p => p.descriptor.toJsonPointer())
- ).toContain('/user/name');
- });
-});
-
-describe('decoding boundary edge cases', () => {
- it('uses output defaults and never re-runs conversion for catch', async () => {
- const convert = vi.fn(() => undefined);
- const schema = decode(string(), number().default(8), convert);
- expect(schema.parse('missing')).toBe(8);
- expect(convert).toHaveBeenCalledTimes(1);
- const fail = vi.fn(() => {
- throw new Error('conversion');
- });
- const fallback = decode(string(), number(), fail).catch(() => 4);
- expect(fallback.parse('x')).toBe(4);
- expect(await fallback.parseAsync('y')).toBe(4);
- expect(fail).toHaveBeenCalledTimes(2);
- });
-
- it('keeps required messages and sync/async rules', async () => {
- const schema = decode(string(), number(), Number).optional();
- expect(
- schema.required('Provide a size').safeParse(undefined).errors?.[0]
- .message
- ).toBe('Provide a size');
- const asyncRequired = schema.required(async () => 'Async required');
- expect(() => asyncRequired.parse(undefined)).toThrow(/Async|async/);
- expect(
- (await asyncRequired.safeParseAsync(undefined)).errors?.[0].message
- ).toBe('Async required');
- });
-
- it('retains nested errors from both decoding stages', () => {
- const schema = object({
- item: decode(
- object({ input: object({ value: string().minLength(1) }) }),
- object({ output: object({ count: number().min(1) }) }),
- input => ({ output: { count: Number(input.input.value) } })
- )
- });
- const inputFailure = schema.validate({
- item: { input: { value: '' } }
- } as any);
- expect(
- inputFailure
- .getInvalidProperties()
- .map(p => p.descriptor.toJsonPointer())
- ).toContain('/item/input/value');
- const outputFailure = schema.validate({
- item: { input: { value: '0' } }
- } as any);
- expect(
- outputFailure.getErrorsFor(t => t.item.output.count).errors.length
- ).toBeGreaterThan(0);
- expect(
- outputFailure
- .getInvalidProperties()
- .map(p => p.descriptor.toJsonPointer())
- ).toContain('/item/output/count');
- });
-});
diff --git a/libs/schema/src/builders/BoundarySchemaBuilder.ts b/libs/schema/src/builders/BoundarySchemaBuilder.ts
deleted file mode 100644
index c9eac67b..00000000
--- a/libs/schema/src/builders/BoundarySchemaBuilder.ts
+++ /dev/null
@@ -1,528 +0,0 @@
-import {
- type BRAND,
- type InferInput,
- type InferOutput,
- SchemaBuilder,
- type SchemaBuilderProps,
- SYMBOL_HAS_PROPERTIES,
- type ValidationContext,
- type ValidationErrorMessageProvider,
- type ValidationResult
-} from './SchemaBuilder.js';
-
-type AnySchema = SchemaBuilder;
-type BoundaryOutput<
- TOutputSchema,
- TRequired extends boolean,
- TNullable extends boolean,
- TExplicitType
-> =
- | (TRequired extends true
- ? Exclude<
- TExplicitType extends undefined
- ? InferOutput
- : TExplicitType,
- undefined
- >
- :
- | (TExplicitType extends undefined
- ? InferOutput
- : TExplicitType)
- | undefined)
- | (TNullable extends true ? null : never);
-
-type BoundaryProps = Partial> & {
- type: 'reference' | 'decode';
- inputSchema: AnySchema;
- outputSchema: AnySchema;
- converter?: (value: any) => any;
- presence?: boolean;
- nullability?: boolean;
-};
-
-/**
- * Immutable schema boundary returned by {@link schemaRef} and {@link decode}.
- * Use the factories rather than constructing this class. Local modifiers never
- * mutate the input/output schemas or their named definitions.
- * @typeParam TInputSchema - Schema validating values before conversion.
- * @typeParam TOutputSchema - Schema validating converted values.
- * @typeParam TRequired - Whether the output includes undefined.
- * @typeParam TNullable - Whether null is allowed by a use-site modifier.
- * @typeParam THasDefault - Whether the wrapper supplies an output default.
- * @typeParam TExplicitType - Optional static output override.
- * @typeParam TInput - Declared input, including use-site presence modifiers.
- */
-export class BoundarySchemaBuilder<
- TInputSchema extends AnySchema,
- TOutputSchema extends AnySchema,
- TRequired extends boolean = true,
- TNullable extends boolean = false,
- THasDefault extends boolean = false,
- TExplicitType = undefined,
- TInput = InferInput
-> extends SchemaBuilder<
- BoundaryOutput,
- TRequired,
- TNullable,
- THasDefault,
- {},
- InferInput,
- TInput | (THasDefault extends true ? undefined : never)
-> {
- readonly #boundary: BoundaryProps;
- /** @internal Enables nested property descriptors without subclassing objects. */
- readonly [SYMBOL_HAS_PROPERTIES] = true;
-
- /** @internal Factory configuration is exposed for immutable reconstruction. */
- constructor(props: BoundaryProps) {
- super({ preprocessors: [], validators: [], ...props });
- this.#boundary = props;
- }
-
- /** Describes both sides without evaluating the converter. */
- public introspect() {
- return {
- ...super.introspect(),
- inputSchema: this.#boundary.inputSchema as TInputSchema,
- outputSchema: this.#boundary.outputSchema as TOutputSchema,
- converter: this.#boundary.converter,
- presence: this.#boundary.presence,
- nullability: this.#boundary.nullability,
- // Object consumers can preserve real descriptor schemas through refs.
- properties:
- this.type === 'reference'
- ? (this.#boundary.outputSchema.introspect() as any)
- .properties
- : undefined
- };
- }
-
- /** @internal Rebuilds the wrapper while retaining the same target instances. */
- protected createFromProps(props: BoundaryProps): this {
- return new BoundarySchemaBuilder(props) as this;
- }
-
- #early(value: unknown): ValidationResult | undefined {
- if (value === undefined && this.hasDefault) return undefined;
- if (value === undefined && this.#boundary.presence !== undefined) {
- return this.#boundary.presence
- ? { valid: false, errors: [{ message: 'is required' }] }
- : { valid: true, object: undefined };
- }
- if (value === null && this.#boundary.nullability !== undefined) {
- return this.#boundary.nullability
- ? { valid: true, object: null }
- : { valid: false, errors: [{ message: 'must not be null' }] };
- }
- return undefined;
- }
-
- #converterFailure(error: unknown): ValidationResult {
- return {
- valid: false,
- errors: [
- {
- message: `Decoder failed: ${error instanceof Error ? error.message : String(error)}`
- }
- ]
- };
- }
-
- #stageFailure(
- result: ValidationResult,
- schema: AnySchema
- ): ValidationResult {
- return Object.assign(result, { __boundaryErrorSchema: schema });
- }
-
- /** Validates unknown input; an output fallback is never decoded a second time. */
- public validate(
- value: unknown,
- context?: ValidationContext
- ): ValidationResult<
- BoundaryOutput
- > {
- const result = this._validate(value, context);
- if (result.valid || !this.hasCatch) return result;
- const fallback = this.resolveCatchValue();
- const checked = this.#boundary.outputSchema.validate(fallback, context);
- return checked.valid ? checked : { valid: true, object: fallback };
- }
-
- /** Async validation with the same output-fallback and error semantics as validate. */
- public async validateAsync(
- value: unknown,
- context?: ValidationContext
- ): Promise<
- ValidationResult<
- BoundaryOutput
- >
- > {
- const result = await this._validateAsync(value, context);
- if (result.valid || !this.hasCatch) return result;
- const fallback = this.resolveCatchValue();
- const checked = await this.#boundary.outputSchema.validateAsync(
- fallback,
- context
- );
- return checked.valid ? checked : { valid: true, object: fallback };
- }
-
- /** @internal Validates input, converts once, and validates output. */
- protected _validate(
- value: unknown,
- context?: ValidationContext
- ): ValidationResult {
- const early = this.#early(value);
- if (early) {
- if (!early.valid && value === undefined)
- early.errors = [
- {
- message: this.getValidationErrorMessageSync(
- this.requiredErrorMessage,
- value as any
- )
- }
- ];
- return early;
- }
- const usesDefault = value === undefined && this.hasDefault;
- const input = usesDefault
- ? { valid: true, object: this.resolveDefaultValue() }
- : this.#boundary.inputSchema.validate(value, context);
- if (!input.valid)
- return this.#stageFailure(input, this.#boundary.inputSchema);
- let converted = input.object;
- if (!usesDefault && this.#boundary.converter) {
- try {
- converted = this.#boundary.converter(converted);
- } catch (error) {
- return this.#converterFailure(error);
- }
- if (converted != null && typeof converted.then === 'function') {
- throw new Error(
- 'Decoder returned a Promise. Use validateAsync() or parseAsync().'
- );
- }
- }
- const output =
- this.type === 'reference' && !usesDefault
- ? input
- : this.#boundary.outputSchema.validate(converted, context);
- if (!output.valid)
- return this.#stageFailure(output, this.#boundary.outputSchema);
- if (output.object === null && this.#boundary.nullability === false) {
- return { valid: false, errors: [{ message: 'must not be null' }] };
- }
- const prepared = this.preValidateSync(output.object, context);
- if (!prepared.valid) return { valid: false, errors: prepared.errors };
- const preparedValue = prepared.transaction!.object.validatedObject;
- if (this.preprocessors.length === 0) return output;
- return this.#boundary.outputSchema.validate(preparedValue, context);
- }
-
- /** @internal Async counterpart; converter rejections are validation failures. */
- protected async _validateAsync(
- value: unknown,
- context?: ValidationContext
- ): Promise> {
- const early = this.#early(value);
- if (early) {
- if (!early.valid && value === undefined)
- early.errors = [
- {
- message: await this.getValidationErrorMessage(
- this.requiredErrorMessage,
- value as any
- )
- }
- ];
- return early;
- }
- const usesDefault = value === undefined && this.hasDefault;
- const input = usesDefault
- ? { valid: true, object: this.resolveDefaultValue() }
- : await this.#boundary.inputSchema.validateAsync(value, context);
- if (!input.valid)
- return this.#stageFailure(input, this.#boundary.inputSchema);
- let converted = input.object;
- if (!usesDefault && this.#boundary.converter) {
- try {
- converted = await this.#boundary.converter(converted);
- } catch (error) {
- return this.#converterFailure(error);
- }
- }
- const output =
- this.type === 'reference' && !usesDefault
- ? input
- : await this.#boundary.outputSchema.validateAsync(
- converted,
- context
- );
- if (!output.valid)
- return this.#stageFailure(output, this.#boundary.outputSchema);
- if (output.object === null && this.#boundary.nullability === false) {
- return { valid: false, errors: [{ message: 'must not be null' }] };
- }
- const prepared = await this.preValidateAsync(output.object, context);
- if (!prepared.valid) return { valid: false, errors: prepared.errors };
- const preparedValue = prepared.transaction!.object.validatedObject;
- if (this.preprocessors.length === 0) return output;
- return this.#boundary.outputSchema.validateAsync(
- preparedValue,
- context
- );
- }
-
- /** Overrides only the static output type; runtime validation is unchanged. */
- public hasType(
- _notUsed?: T
- ): BoundarySchemaBuilder<
- TInputSchema,
- TOutputSchema,
- TRequired,
- TNullable,
- THasDefault,
- T,
- TInput
- > {
- return this.createFromProps(this.#props()) as any;
- }
-
- /** Restores output inference from the output schema. */
- public clearHasType(): BoundarySchemaBuilder<
- TInputSchema,
- TOutputSchema,
- TRequired,
- TNullable,
- THasDefault,
- undefined,
- TInput
- > {
- return this.createFromProps(this.#props()) as any;
- }
-
- #props(): BoundaryProps {
- return {
- ...this.#boundary,
- ...this.introspect(),
- type: this.#boundary.type,
- preprocessors: [...this.preprocessors],
- validators: [...this.validators]
- };
- }
-
- /** Rejects missing input at this use site. */
- public required(
- errorMessage?: ValidationErrorMessageProvider
- ): BoundarySchemaBuilder<
- TInputSchema,
- TOutputSchema,
- true,
- TNullable,
- THasDefault,
- TExplicitType,
- Exclude
- > {
- return this.createFromProps({
- ...this.#props(),
- isRequired: true,
- presence: true,
- requiredValidationErrorMessageProvider: errorMessage
- }) as any;
- }
-
- /** Allows omitted input at this use site, without invoking the boundary. */
- public optional(): BoundarySchemaBuilder<
- TInputSchema,
- TOutputSchema,
- false,
- TNullable,
- THasDefault,
- TExplicitType,
- TInput | undefined
- > {
- return this.createFromProps({
- ...this.#props(),
- isRequired: false,
- presence: false
- }) as any;
- }
-
- /** Allows null at this use site, independently of optionality. */
- public nullable(): BoundarySchemaBuilder<
- TInputSchema,
- TOutputSchema,
- TRequired,
- true,
- THasDefault,
- TExplicitType,
- TInput | null
- > {
- return this.createFromProps({
- ...this.#props(),
- isNullable: true,
- nullability: true
- }) as any;
- }
-
- /** Rejects null at this use site without changing the referenced definition. */
- public notNullable(): BoundarySchemaBuilder<
- TInputSchema,
- TOutputSchema,
- TRequired,
- false,
- THasDefault,
- Exclude<
- TExplicitType extends undefined
- ? InferOutput
- : TExplicitType,
- null
- >,
- Exclude
- > {
- return this.createFromProps({
- ...this.#props(),
- isNullable: false,
- nullability: false
- }) as any;
- }
-
- /** Supplies an output default for missing input; validates it without decoding. */
- public default(
- value: InferOutput | (() => InferOutput)
- ): BoundarySchemaBuilder<
- TInputSchema,
- TOutputSchema,
- true,
- TNullable,
- true,
- TExplicitType,
- TInput
- > {
- return this.createFromProps({
- ...this.#props(),
- defaultValue: value,
- isRequired: true
- }) as any;
- }
-
- /** Removes the use-site default; defaults on the target are unchanged. */
- public clearDefault(): BoundarySchemaBuilder<
- TInputSchema,
- TOutputSchema,
- TRequired,
- TNullable,
- false,
- TExplicitType,
- TInput
- > {
- return this.createFromProps({
- ...this.#props(),
- defaultValue: undefined
- }) as any;
- }
-
- /** Brands the output type only; it does not alter runtime validation. */
- public brand(
- _name?: B
- ): BoundarySchemaBuilder<
- TInputSchema,
- TOutputSchema,
- TRequired,
- TNullable,
- THasDefault,
- (TExplicitType extends undefined
- ? InferOutput
- : TExplicitType) & {
- readonly [K in BRAND]: B;
- },
- TInput
- > {
- return super.brand(_name);
- }
-
- /** Makes the output type readonly without freezing runtime values. */
- public readonly(): BoundarySchemaBuilder<
- TInputSchema,
- TOutputSchema,
- TRequired,
- TNullable,
- THasDefault,
- Readonly<
- TExplicitType extends undefined
- ? InferOutput
- : TExplicitType
- >,
- TInput
- > {
- return super.readonly();
- }
-}
-
-type RequiredOf = undefined extends InferOutput ? false : true;
-type NullableOf = null extends InferOutput ? true : false;
-
-/**
- * References one named definition with independent use-site annotations.
- * @param schema - A reused schema constant with a nonempty schemaName.
- * @returns An immutable reference wrapper; the named target is not cloned.
- * @throws Error if the target has no name.
- * @example
- * const User = object({ name: string() }).schemaName('User');
- * const previous = schemaRef(User).nullable().optional().describe('Previous user');
- */
-export function schemaRef(
- schema: S
-): BoundarySchemaBuilder, NullableOf> {
- const info = schema.introspect();
- if (!info.schemaName?.trim())
- throw new Error('schemaRef requires a named schema.');
- return new BoundarySchemaBuilder({
- type: 'reference',
- inputSchema: schema,
- outputSchema: schema,
- isRequired: info.isRequired,
- isNullable: info.isNullable
- });
-}
-
-/**
- * Composes input validation, an explicit conversion, and output validation.
- * @param inputSchema - Validates external input before the converter runs.
- * @param outputSchema - Validates the converted result.
- * @param converter - Receives validated input; may return a Promise.
- * @returns A schema with distinct inferred input/output types.
- * @remarks Converter exceptions become validation failures. Async converters
- * require parseAsync/validateAsync. JSON Schema documents declared shapes only,
- * not arbitrary conversion logic. This is not a bidirectional codec.
- * @example
- * const PageSize = decode(string(), number().isInteger().min(1), Number);
- * PageSize.parse('12'); // 12
- */
-export function decode<
- TInputSchema extends AnySchema,
- TOutputSchema extends AnySchema
->(
- inputSchema: TInputSchema,
- outputSchema: TOutputSchema,
- converter: (
- input: InferOutput
- ) => InferInput | Promise>
-): BoundarySchemaBuilder<
- TInputSchema,
- TOutputSchema,
- RequiredOf,
- NullableOf
-> {
- const info = outputSchema.introspect();
- return new BoundarySchemaBuilder({
- type: 'decode',
- inputSchema,
- outputSchema,
- converter,
- isRequired: info.isRequired,
- isNullable: info.isNullable
- });
-}
diff --git a/libs/schema/src/builders/ExternSchemaBuilder.ts b/libs/schema/src/builders/ExternSchemaBuilder.ts
index 76fb55ec..6d4fb12d 100644
--- a/libs/schema/src/builders/ExternSchemaBuilder.ts
+++ b/libs/schema/src/builders/ExternSchemaBuilder.ts
@@ -100,8 +100,7 @@ export class ExternSchemaBuilder<
TRequired,
TNullable,
THasDefault,
- TExtensions,
- StandardSchemaV1.InferInput
+ TExtensions
> {
#standardSchema: TStandardSchema;
diff --git a/libs/schema/src/builders/IntersectionSchemaBuilder.ts b/libs/schema/src/builders/IntersectionSchemaBuilder.ts
index 47488a6b..6d746c6a 100644
--- a/libs/schema/src/builders/IntersectionSchemaBuilder.ts
+++ b/libs/schema/src/builders/IntersectionSchemaBuilder.ts
@@ -1,6 +1,5 @@
import {
type BRAND,
- type InferInput,
type InferType,
SchemaBuilder,
type ValidationContext,
@@ -37,8 +36,7 @@ export class IntersectionSchemaBuilder<
TRequired,
TNullable,
THasDefault,
- TExtensions,
- InferInput & InferInput
+ TExtensions
> {
#left!: TLeft;
#right!: TRight;
diff --git a/libs/schema/src/builders/LazySchemaBuilder.ts b/libs/schema/src/builders/LazySchemaBuilder.ts
index cc0ede6c..12f0d7c5 100644
--- a/libs/schema/src/builders/LazySchemaBuilder.ts
+++ b/libs/schema/src/builders/LazySchemaBuilder.ts
@@ -1,7 +1,5 @@
import {
type BRAND,
- type InferInput,
- type InferOutput,
SchemaBuilder,
type ValidationContext,
type ValidationErrorMessageProvider,
@@ -51,15 +49,13 @@ export class LazySchemaBuilder<
TRequired extends boolean = true,
TNullable extends boolean = false,
THasDefault extends boolean = false,
- TExtensions = {},
- TInput = TResult
+ TExtensions = {}
> extends SchemaBuilder<
TResult,
TRequired,
TNullable,
THasDefault,
- TExtensions,
- TInput
+ TExtensions
> {
#getter: () => SchemaBuilder;
#resolvedSchema: SchemaBuilder | null = null;
@@ -214,7 +210,7 @@ export class LazySchemaBuilder<
*/
public hasType(
_notUsed?: T
- ): LazySchemaBuilder &
+ ): LazySchemaBuilder &
TExtensions {
return this.createFromProps({
...this.introspect()
@@ -229,8 +225,7 @@ export class LazySchemaBuilder<
TRequired,
TNullable,
THasDefault,
- TExtensions,
- TInput
+ TExtensions
> &
TExtensions {
return this.createFromProps({
@@ -243,14 +238,7 @@ export class LazySchemaBuilder<
*/
public required(
errorMessage?: ValidationErrorMessageProvider
- ): LazySchemaBuilder<
- TResult,
- true,
- TNullable,
- THasDefault,
- TExtensions,
- TInput
- > &
+ ): LazySchemaBuilder &
TExtensions {
return super.required(errorMessage);
}
@@ -263,8 +251,7 @@ export class LazySchemaBuilder<
false,
TNullable,
THasDefault,
- TExtensions,
- TInput
+ TExtensions
> &
TExtensions {
return super.optional();
@@ -275,7 +262,7 @@ export class LazySchemaBuilder<
*/
public default(
value: TResult | (() => TResult)
- ): LazySchemaBuilder &
+ ): LazySchemaBuilder &
TExtensions {
return super.default(value) as any;
}
@@ -288,8 +275,7 @@ export class LazySchemaBuilder<
TRequired,
TNullable,
false,
- TExtensions,
- TInput
+ TExtensions
> &
TExtensions {
return super.clearDefault() as any;
@@ -305,8 +291,7 @@ export class LazySchemaBuilder<
TRequired,
TNullable,
THasDefault,
- TExtensions,
- TInput
+ TExtensions
> &
TExtensions {
return super.brand(_name);
@@ -320,8 +305,7 @@ export class LazySchemaBuilder<
TRequired,
TNullable,
THasDefault,
- TExtensions,
- TInput
+ TExtensions
> &
TExtensions {
return super.readonly();
@@ -335,8 +319,7 @@ export class LazySchemaBuilder<
TRequired,
true,
THasDefault,
- TExtensions,
- TInput
+ TExtensions
> &
TExtensions {
return super.nullable() as any;
@@ -350,8 +333,7 @@ export class LazySchemaBuilder<
TRequired,
false,
THasDefault,
- TExtensions,
- TInput
+ TExtensions
> &
TExtensions {
return super.notNullable() as any;
@@ -390,12 +372,6 @@ export class LazySchemaBuilder<
* });
* ```
*/
-export function lazy>(
- getter: () => S
-): LazySchemaBuilder, true, false, false, {}, InferInput>;
-export function lazy(
- getter: () => SchemaBuilder
-): LazySchemaBuilder;
export function lazy(
getter: () => SchemaBuilder
): LazySchemaBuilder {
diff --git a/libs/schema/src/builders/ObjectSchemaBuilder.ts b/libs/schema/src/builders/ObjectSchemaBuilder.ts
index 4ce3529a..5563d7f0 100644
--- a/libs/schema/src/builders/ObjectSchemaBuilder.ts
+++ b/libs/schema/src/builders/ObjectSchemaBuilder.ts
@@ -1,7 +1,6 @@
import { PropertyValidationResult } from './PropertyValidationResult.js';
import {
type BRAND,
- type InferInput,
type InferType,
type NestedValidationResult,
type PreValidationResult,
@@ -138,9 +137,9 @@ export type RespectPropsOptionality<
type RespectPropsOptionalityForInput<
T extends Record>
> = {
- [K in RequiredInputProps]: InferInput;
+ [K in RequiredInputProps]: InferType;
} & {
- [K in NotRequiredInputProps]?: InferInput;
+ [K in NotRequiredInputProps]?: InferType;
};
type MakeChildrenRequired<
@@ -377,8 +376,7 @@ export class ObjectSchemaBuilder<
TRequired,
TNullable,
THasDefault,
- TExtensions,
- RespectPropsOptionalityForInput
+ TExtensions
> {
#properties: TProperties = {} as any;
#acceptUnknownProps = false;
@@ -1076,8 +1074,7 @@ export class ObjectSchemaBuilder<
result as any,
descriptor,
addErrorFor,
- (result as any).__boundaryErrorSchema ??
- this.#properties[key]
+ this.#properties[key]
);
// For extern schemas, also record errors on the extern
// descriptor itself so getErrorsFor(t => t.extern) works,
@@ -1846,8 +1843,7 @@ export class ObjectSchemaBuilder<
TRequired,
TNullable,
THasDefault,
- TExtensions,
- RespectPropsOptionalityForInput
+ TExtensions
> {
return this.createFromProps({
...this.introspect()
@@ -2769,7 +2765,6 @@ export class ObjectSchemaBuilder<
): void {
const schema =
schemaOverride ??
- result.__boundaryErrorSchema ??
ObjectSchemaBuilder.#getSchemaForPropertyDescriptor(descriptor);
const properties = (schema.introspect() as any).properties;
@@ -3123,11 +3118,33 @@ type NotRequiredProps<
type RequiredInputProps<
T extends Record>
> = keyof {
- [k in keyof T as undefined extends InferInput ? never : k]: T[k];
+ [k in keyof T as T[k] extends SchemaBuilder<
+ any,
+ infer TReq,
+ any,
+ infer THasDef
+ >
+ ? TReq extends true
+ ? THasDef extends true
+ ? never
+ : k
+ : never
+ : never]: T[k];
};
type NotRequiredInputProps<
T extends Record>
> = keyof {
- [k in keyof T as undefined extends InferInput ? k : never]: T[k];
+ [k in keyof T as T[k] extends SchemaBuilder<
+ any,
+ infer TReq,
+ any,
+ infer THasDef
+ >
+ ? TReq extends true
+ ? THasDef extends true
+ ? k
+ : never
+ : k
+ : never]: T[k];
};
diff --git a/libs/schema/src/builders/RecordSchemaBuilder.ts b/libs/schema/src/builders/RecordSchemaBuilder.ts
index 5b0e306d..f9e2ab63 100644
--- a/libs/schema/src/builders/RecordSchemaBuilder.ts
+++ b/libs/schema/src/builders/RecordSchemaBuilder.ts
@@ -14,7 +14,6 @@
*/
import {
type BRAND,
- type InferInput,
type InferType,
type PreValidationResult,
SchemaBuilder,
@@ -242,8 +241,7 @@ export class RecordSchemaBuilder<
TRequired,
TNullable,
THasDefault,
- TExtensions,
- Record, InferInput>
+ TExtensions
> {
#keySchema!: TKeySchema;
#valueSchema!: TValueSchema;
diff --git a/libs/schema/src/builders/ReferenceSchemaBuilder.test.ts b/libs/schema/src/builders/ReferenceSchemaBuilder.test.ts
new file mode 100644
index 00000000..01ea34f4
--- /dev/null
+++ b/libs/schema/src/builders/ReferenceSchemaBuilder.test.ts
@@ -0,0 +1,149 @@
+import { describe, expect, it, vi } from 'vitest';
+import { array, boolean, number, object, schemaRef, string } from '../index.js';
+
+describe('optional fallbacks', () => {
+ it('accepts optional/nullable fallbacks and preserves legacy null acceptance', async () => {
+ const text = string().optional().catch(undefined);
+ expect(text.parse(42)).toBeUndefined();
+ expect(await text.parseAsync(false)).toBeUndefined();
+ expect(text.parse(null)).toBeNull();
+ expect(string().nullable().catch(null).parse(42)).toBeNull();
+ const normalize = string()
+ .optional()
+ .addPreprocessor(v => (v == null ? undefined : v))
+ .catch(undefined);
+ expect(normalize.parse(null)).toBeUndefined();
+ expect(object({ text }).safeParse(null).valid).toBe(false);
+ expect(
+ object({
+ text,
+ flag: boolean()
+ .optional()
+ .catch(() => undefined)
+ }).parse({ text: {}, flag: 1 })
+ ).toEqual({ text: undefined, flag: undefined });
+ });
+
+ it('supports async optional preprocessing without dropping array entries', async () => {
+ const fallback = vi.fn(() => undefined);
+ const text = string().optional().catch(fallback);
+ expect(array(text).parse(['ok', 42])).toEqual(['ok', undefined]);
+ expect(fallback).toHaveBeenCalledTimes(1);
+ const normalized = string()
+ .optional()
+ .addPreprocessor(async v => (v == null ? undefined : v));
+ expect(await normalized.parseAsync(null)).toBeUndefined();
+ expect(() => normalized.parse(null)).toThrow(/validateAsync/);
+ });
+});
+
+describe('named schema references', () => {
+ it('preserves one target definition and independent local metadata', () => {
+ const user = object({ name: string().minLength(1) }).schemaName('User');
+ const ref = schemaRef(user);
+ const previous = ref
+ .nullable()
+ .optional()
+ .describe('Previous user')
+ .example(null);
+ expect(ref.introspect().targetSchema).toBe(user);
+ expect(previous.introspect().targetSchema).toBe(user);
+ expect(user.introspect().description).toBeUndefined();
+ expect(user.introspect().isRequired).toBe(true);
+ expect(ref.safeParse(null).valid).toBe(false);
+ expect(previous.parse(null)).toBeNull();
+ expect(previous.parse(undefined)).toBeUndefined();
+ expect(previous.parse({ name: 'Ada' })).toEqual({ name: 'Ada' });
+ expect(() => schemaRef(string())).toThrow(/named schema/);
+ expect(() => schemaRef(string().schemaName(' '))).toThrow(
+ /named schema/
+ );
+ });
+
+ it('preserves deeply nested reference errors and selectors', async () => {
+ const address = object({ city: string().minLength(1) }).schemaName(
+ 'Address'
+ );
+ const user = object({ address: schemaRef(address) }).schemaName('User');
+ const root = object({ user: schemaRef(user) });
+ for (const result of [
+ root.validate({ user: { address: { city: '' } } }),
+ await root.validateAsync({ user: { address: { city: '' } } })
+ ]) {
+ expect(result.valid).toBe(false);
+ expect(
+ result.getErrorsFor(t => t.user.address.city).errors.length
+ ).toBeGreaterThan(0);
+ expect(
+ result
+ .getInvalidProperties()
+ .map(p => p.descriptor.toJsonPointer())
+ ).toContain('/user/address/city');
+ }
+ });
+
+ it('keeps target defaults, local defaults and fallbacks independent', async () => {
+ const target = number().default(2).schemaName('Size');
+ const ref = schemaRef(target);
+ expect(ref.parse(undefined)).toBe(2);
+ expect(ref.optional().parse(undefined)).toBeUndefined();
+ expect(ref.optional().default(4).parse(undefined)).toBe(4);
+ expect(ref.default(4).clearDefault().parse(undefined)).toBe(2);
+ expect(target.parse(undefined)).toBe(2);
+ const fallback = vi.fn(() => 8);
+ const caught = ref.catch(fallback);
+ expect(caught.parse('invalid')).toBe(8);
+ expect(await caught.parseAsync('invalid')).toBe(8);
+ expect(fallback).toHaveBeenCalledTimes(2);
+ expect(ref.optional().catch(undefined).parse({})).toBeUndefined();
+ });
+
+ it('keeps local optionality, nullability and required error messages distinct', async () => {
+ const ref = schemaRef(
+ string().nullable().optional().schemaName('Text')
+ );
+ expect(ref.parse(null)).toBeNull();
+ expect(ref.notNullable().safeParse(null).valid).toBe(false);
+ expect(ref.notNullable().parse(undefined)).toBeUndefined();
+ const required = ref.required('Provide text');
+ expect(required.safeParse(undefined).errors?.[0].message).toBe(
+ 'Provide text'
+ );
+ expect(required.parse(null)).toBeNull();
+ const asyncRequired = ref.required(async () => 'Async required');
+ expect(() => asyncRequired.parse(undefined)).toThrow(/Async|async/);
+ expect(
+ (await asyncRequired.safeParseAsync(undefined)).errors?.[0].message
+ ).toBe('Async required');
+ });
+
+ it('delegates async target validation and runs local callbacks', async () => {
+ const target = string()
+ .addPreprocessor(async v => v.trim())
+ .schemaName('Trimmed');
+ const ref = schemaRef(target);
+ expect(() => ref.parse(' text ')).toThrow(/validateAsync/);
+ expect(await ref.parseAsync(' text ')).toBe('text');
+ expect(await ref['~standard'].validate(' text ')).toEqual({
+ value: 'text'
+ });
+ const local = schemaRef(number().schemaName('Count'))
+ .addPreprocessor(v => v + 1)
+ .addValidator(v => ({ valid: v < 5 }));
+ expect(local.parse(2)).toBe(3);
+ expect(local.safeParse(5).valid).toBe(false);
+ expect(
+ schemaRef(string().schemaName('OptionalText'))
+ .optional()
+ .addPreprocessor(() => undefined)
+ .parse('text')
+ ).toBeUndefined();
+ });
+
+ it('retains existing hasType behavior without changing runtime validation', () => {
+ const ref = schemaRef(number().schemaName('Count')).hasType();
+ expect(ref.parse(3)).toBe(3);
+ expect(ref.safeParse('three').valid).toBe(false);
+ expect(ref.clearHasType().parse(4)).toBe(4);
+ });
+});
diff --git a/libs/schema/src/builders/ReferenceSchemaBuilder.ts b/libs/schema/src/builders/ReferenceSchemaBuilder.ts
new file mode 100644
index 00000000..fa11afc2
--- /dev/null
+++ b/libs/schema/src/builders/ReferenceSchemaBuilder.ts
@@ -0,0 +1,387 @@
+import {
+ type BRAND,
+ type InferType,
+ SchemaBuilder,
+ type SchemaBuilderProps,
+ SYMBOL_HAS_PROPERTIES,
+ type ValidationContext,
+ type ValidationErrorMessageProvider,
+ type ValidationResult
+} from './SchemaBuilder.js';
+
+type AnySchema = SchemaBuilder;
+type ReferenceValue<
+ TSchema,
+ TRequired extends boolean,
+ TNullable extends boolean,
+ TExplicitType
+> =
+ | (TRequired extends true
+ ? Exclude<
+ TExplicitType extends undefined
+ ? InferType
+ : TExplicitType,
+ undefined
+ >
+ :
+ | (TExplicitType extends undefined
+ ? InferType
+ : TExplicitType)
+ | undefined)
+ | (TNullable extends true ? null : never);
+type ReferenceProps = Partial> & {
+ targetSchema: AnySchema;
+ presence?: boolean;
+ nullability?: boolean;
+};
+
+/**
+ * Immutable use-site wrapper around one named schema. Create it with schemaRef.
+ * Local modifiers never clone or mutate the target definition.
+ * @typeParam TSchema - Referenced schema.
+ * @typeParam TRequired - Whether a value is required at this use site.
+ * @typeParam TNullable - Whether null is allowed at this use site.
+ * @typeParam THasDefault - Whether the wrapper supplies a default.
+ * @typeParam TExplicitType - Optional static type override.
+ */
+export class ReferenceSchemaBuilder<
+ TSchema extends AnySchema,
+ TRequired extends boolean = true,
+ TNullable extends boolean = false,
+ THasDefault extends boolean = false,
+ TExplicitType = undefined
+> extends SchemaBuilder<
+ ReferenceValue,
+ TRequired,
+ TNullable,
+ THasDefault
+> {
+ readonly #reference: ReferenceProps;
+ /** @internal Enables nested property descriptors for referenced objects. */
+ readonly [SYMBOL_HAS_PROPERTIES] = true;
+
+ /** @internal Use schemaRef rather than constructing a reference directly. */
+ constructor(props: ReferenceProps) {
+ super({
+ preprocessors: [],
+ validators: [],
+ ...props,
+ type: 'reference'
+ });
+ this.#reference = props;
+ }
+
+ /** Describes the target and local modifiers without changing the target. */
+ public introspect() {
+ return {
+ ...super.introspect(),
+ targetSchema: this.#reference.targetSchema as TSchema,
+ presence: this.#reference.presence,
+ nullability: this.#reference.nullability,
+ properties: (this.#reference.targetSchema.introspect() as any)
+ .properties
+ };
+ }
+
+ /** @internal Reconstructs an immutable wrapper around the same target. */
+ protected createFromProps(props: ReferenceProps): this {
+ return new ReferenceSchemaBuilder(props) as this;
+ }
+
+ /** Validates through the target and retains the inferred result type. */
+ public validate(
+ value: unknown,
+ context?: ValidationContext
+ ): ValidationResult<
+ ReferenceValue
+ > {
+ return super.validate(value, context);
+ }
+
+ /** Async validation with the same inference and fallback behavior. */
+ public validateAsync(
+ value: unknown,
+ context?: ValidationContext
+ ): Promise<
+ ValidationResult<
+ ReferenceValue
+ >
+ > {
+ return super.validateAsync(value, context);
+ }
+
+ #props(): ReferenceProps {
+ return {
+ ...this.#reference,
+ ...this.introspect(),
+ preprocessors: [...this.preprocessors],
+ validators: [...this.validators]
+ };
+ }
+
+ #early(value: unknown): ValidationResult | undefined {
+ if (value === undefined && this.hasDefault) return undefined;
+ if (value === undefined && this.#reference.presence !== undefined) {
+ return this.#reference.presence
+ ? { valid: false, errors: [{ message: 'is required' }] }
+ : { valid: true, object: undefined };
+ }
+ if (value === null && this.#reference.nullability !== undefined) {
+ return this.#reference.nullability
+ ? { valid: true, object: null }
+ : { valid: false, errors: [{ message: 'must not be null' }] };
+ }
+ return undefined;
+ }
+
+ #checkNull(result: ValidationResult): ValidationResult {
+ if (
+ result.valid &&
+ result.object === null &&
+ this.#reference.nullability === false
+ )
+ return { valid: false, errors: [{ message: 'must not be null' }] };
+ return result;
+ }
+
+ /** @internal Delegates validation while applying local presence and validators. */
+ protected _validate(
+ value: unknown,
+ context?: ValidationContext
+ ): ValidationResult {
+ const early = this.#early(value);
+ if (early) {
+ if (!early.valid && value === undefined)
+ early.errors = [
+ {
+ message: this.getValidationErrorMessageSync(
+ this.requiredErrorMessage,
+ value as any
+ )
+ }
+ ];
+ return early;
+ }
+ const result = this.#checkNull(
+ this.#reference.targetSchema.validate(
+ value === undefined && this.hasDefault
+ ? this.resolveDefaultValue()
+ : value,
+ context
+ )
+ );
+ if (!result.valid) return result;
+ const prepared = this.preValidateSync(result.object, context);
+ if (!prepared.valid) return { valid: false, errors: prepared.errors };
+ if (this.preprocessors.length === 0) return result;
+ const preparedValue = prepared.transaction!.object.validatedObject;
+ return this.#checkNull(
+ this.#early(preparedValue) ??
+ this.#reference.targetSchema.validate(preparedValue, context)
+ );
+ }
+
+ /** @internal Async counterpart, including target validators and local callbacks. */
+ protected async _validateAsync(
+ value: unknown,
+ context?: ValidationContext
+ ): Promise> {
+ const early = this.#early(value);
+ if (early) {
+ if (!early.valid && value === undefined)
+ early.errors = [
+ {
+ message: await this.getValidationErrorMessage(
+ this.requiredErrorMessage,
+ value as any
+ )
+ }
+ ];
+ return early;
+ }
+ const result = this.#checkNull(
+ await this.#reference.targetSchema.validateAsync(
+ value === undefined && this.hasDefault
+ ? this.resolveDefaultValue()
+ : value,
+ context
+ )
+ );
+ if (!result.valid) return result;
+ const prepared = await this.preValidateAsync(result.object, context);
+ if (!prepared.valid) return { valid: false, errors: prepared.errors };
+ if (this.preprocessors.length === 0) return result;
+ const preparedValue = prepared.transaction!.object.validatedObject;
+ return this.#checkNull(
+ this.#early(preparedValue) ??
+ (await this.#reference.targetSchema.validateAsync(
+ preparedValue,
+ context
+ ))
+ );
+ }
+
+ /** Overrides the static type only; target validation remains unchanged. */
+ public hasType(
+ _notUsed?: T
+ ): ReferenceSchemaBuilder {
+ return this.createFromProps(this.#props()) as any;
+ }
+
+ /** Restores inference from the referenced schema. */
+ public clearHasType(): ReferenceSchemaBuilder<
+ TSchema,
+ TRequired,
+ TNullable,
+ THasDefault
+ > {
+ return this.createFromProps(this.#props()) as any;
+ }
+
+ /** Rejects omitted values at this use site, unless a local default exists. */
+ public required(
+ errorMessage?: ValidationErrorMessageProvider
+ ): ReferenceSchemaBuilder<
+ TSchema,
+ true,
+ TNullable,
+ THasDefault,
+ TExplicitType
+ > {
+ return this.createFromProps({
+ ...this.#props(),
+ isRequired: true,
+ presence: true,
+ requiredValidationErrorMessageProvider: errorMessage
+ }) as any;
+ }
+
+ /** Allows omission without invoking the target, unless a local default exists. */
+ public optional(): ReferenceSchemaBuilder<
+ TSchema,
+ false,
+ TNullable,
+ THasDefault,
+ TExplicitType
+ > {
+ return this.createFromProps({
+ ...this.#props(),
+ isRequired: false,
+ presence: false
+ }) as any;
+ }
+
+ /** Allows null independently of omission at this use site. */
+ public nullable(): ReferenceSchemaBuilder<
+ TSchema,
+ TRequired,
+ true,
+ THasDefault,
+ TExplicitType
+ > {
+ return this.createFromProps({
+ ...this.#props(),
+ isNullable: true,
+ nullability: true
+ }) as any;
+ }
+
+ /** Rejects null without modifying the target definition. */
+ public notNullable(): ReferenceSchemaBuilder<
+ TSchema,
+ TRequired,
+ false,
+ THasDefault,
+ Exclude<
+ TExplicitType extends undefined
+ ? InferType
+ : TExplicitType,
+ null
+ >
+ > {
+ return this.createFromProps({
+ ...this.#props(),
+ isNullable: false,
+ nullability: false
+ }) as any;
+ }
+
+ /** Supplies a local default for omission, validated by the target. */
+ public default(
+ value: InferType | (() => InferType)
+ ): ReferenceSchemaBuilder {
+ return this.createFromProps({
+ ...this.#props(),
+ defaultValue: value,
+ isRequired: true
+ }) as any;
+ }
+
+ /** Removes the local default; target defaults are unchanged. */
+ public clearDefault(): ReferenceSchemaBuilder<
+ TSchema,
+ TRequired,
+ TNullable,
+ false,
+ TExplicitType
+ > {
+ return this.createFromProps({
+ ...this.#props(),
+ defaultValue: undefined
+ }) as any;
+ }
+
+ /** Brands the inferred type without changing runtime validation. */
+ public brand(
+ _name?: B
+ ): ReferenceSchemaBuilder<
+ TSchema,
+ TRequired,
+ TNullable,
+ THasDefault,
+ (TExplicitType extends undefined
+ ? InferType
+ : TExplicitType) & { readonly [K in BRAND]: B }
+ > {
+ return super.brand(_name);
+ }
+
+ /** Makes the inferred type readonly without freezing runtime values. */
+ public readonly(): ReferenceSchemaBuilder<
+ TSchema,
+ TRequired,
+ TNullable,
+ THasDefault,
+ Readonly<
+ TExplicitType extends undefined ? InferType : TExplicitType
+ >
+ > {
+ return super.readonly();
+ }
+}
+
+/**
+ * References one named definition with independent use-site annotations.
+ * @param schema - A reused schema constant with a nonempty schemaName.
+ * @returns An immutable reference wrapper; the target is neither cloned nor mutated.
+ * @throws Error if the target has no name.
+ * @example
+ * const User = object({ name: string() }).schemaName('User');
+ * const previous = schemaRef(User).nullable().optional().describe('Previous user');
+ */
+export function schemaRef(
+ schema: TSchema
+): ReferenceSchemaBuilder<
+ TSchema,
+ undefined extends InferType ? false : true,
+ null extends InferType ? true : false
+> {
+ const info = schema.introspect();
+ if (!info.schemaName?.trim())
+ throw new Error('schemaRef requires a named schema.');
+ return new ReferenceSchemaBuilder({
+ targetSchema: schema,
+ isRequired: info.isRequired,
+ isNullable: info.isNullable
+ });
+}
diff --git a/libs/schema/src/builders/SchemaBuilder.ts b/libs/schema/src/builders/SchemaBuilder.ts
index 311e86ed..5fdc8671 100644
--- a/libs/schema/src/builders/SchemaBuilder.ts
+++ b/libs/schema/src/builders/SchemaBuilder.ts
@@ -9,8 +9,6 @@ import type { ObjectSchemaBuilder } from './ObjectSchemaBuilder.js';
/** @internal Symbol used as the key for the type brand on schema builders. */
declare const __type: unique symbol;
-/** @internal Input type marker. */
-declare const __input: unique symbol;
/** @internal */
export type SchemaTypeBrand = typeof __type;
@@ -59,18 +57,6 @@ export type InferType = T extends {
? TType
: T;
-/** Validated output of a schema; compatibility alias for {@link InferType}. */
-export type InferOutput = InferType;
-
-/**
- * Declared input before decoding and defaults. Parsers still validate unknown
- * data at runtime. Legacy preprocessors do not infer a separate input type.
- * @typeParam T - Schema whose input is required.
- */
-export type InferInput = T extends { readonly [__input]: infer I }
- ? I
- : InferType;
-
/**
* Represents a single validation error with a human-readable error message.
*
@@ -583,7 +569,7 @@ export type PropertyDescriptorTree<
K & string
> &
ExternOutputPropertyDescriptors<
- NonNullable>,
+ NonNullable>,
TRootSchema,
PropertyDescriptor<
TRootSchema,
@@ -751,8 +737,6 @@ type ResolvedSchemaType<
* **Note:** this class is not intended to be used directly, use one of the subclasses instead.
* @typeparam TResult Type of the object that will be returned by `validate()` method.
* @typeparam TRequired If `true`, object will be required. If `false`, object will be optional.
- * @typeParam TInput - Declared input before decoding. Defaults to TResult so
- * existing generic arguments retain their meaning.
*/
export abstract class SchemaBuilder<
TResult = any,
@@ -760,11 +744,7 @@ export abstract class SchemaBuilder<
TNullable extends boolean = false,
THasDefault extends boolean = false,
// biome-ignore lint/correctness/noUnusedVariables: used in extensions
- TExtensions = {},
- TInput = TResult,
- TResolvedInput =
- | ResolvedSchemaType
- | (THasDefault extends true ? undefined : never)
+ TExtensions = {}
> {
#isRequired = true;
#isNullable = false;
@@ -792,7 +772,6 @@ export abstract class SchemaBuilder<
*/
#standardProps:
| StandardSchemaV1.Props<
- TResolvedInput,
ResolvedSchemaType
>
| undefined;
@@ -815,9 +794,6 @@ export abstract class SchemaBuilder<
*/
declare readonly [__hasDefault]: THasDefault;
- /** @internal Input type, including omission when a default exists. */
- declare readonly [__input]: TResolvedInput;
-
/**
* Standard Schema v1 interface.
*
@@ -868,7 +844,6 @@ export abstract class SchemaBuilder<
* @see https://standardschema.dev/
*/
get ['~standard'](): StandardSchemaV1.Props<
- TResolvedInput,
ResolvedSchemaType
> {
if (this.#standardProps) return this.#standardProps;
@@ -1783,7 +1758,7 @@ export abstract class SchemaBuilder<
* @param options - Whether the callback can mutate its argument.
* @returns A new builder preserving the original schema.
* @remarks Legacy callback parameter typing is preserved for compatibility.
- * For a separately typed external input, use decode(input, output, fn).
+ * Unknown external data still needs runtime validation.
*/
public addPreprocessor(
preprocessor: (
diff --git a/libs/schema/src/builders/TupleSchemaBuilder.ts b/libs/schema/src/builders/TupleSchemaBuilder.ts
index d2d690ef..8a5a6d76 100644
--- a/libs/schema/src/builders/TupleSchemaBuilder.ts
+++ b/libs/schema/src/builders/TupleSchemaBuilder.ts
@@ -5,7 +5,6 @@ import type {
import {
type BRAND,
createHybridErrorArray,
- type InferInput,
type InferType,
type NestedValidationResult,
type PropertyDescriptor,
@@ -161,13 +160,7 @@ export class TupleSchemaBuilder<
TRequired,
TNullable,
THasDefault,
- TExtensions,
- TRestSchema extends SchemaBuilder
- ? [
- ...{ [K in keyof TElements]: InferInput },
- ...Array>
- ]
- : { [K in keyof TElements]: InferInput }
+ TExtensions
> {
#elements!: TElements;
#restSchema:
diff --git a/libs/schema/src/builders/UnionSchemaBuilder.ts b/libs/schema/src/builders/UnionSchemaBuilder.ts
index 9ac80679..0560e4c4 100644
--- a/libs/schema/src/builders/UnionSchemaBuilder.ts
+++ b/libs/schema/src/builders/UnionSchemaBuilder.ts
@@ -5,7 +5,6 @@ import type {
import {
type BRAND,
createHybridErrorArray,
- type InferInput,
type InferType,
type NestedValidationResult,
type PropertyDescriptor,
@@ -173,8 +172,7 @@ export class UnionSchemaBuilder<
TRequired,
TNullable,
THasDefault,
- TExtensions,
- InferInput
+ TExtensions
> {
#options!: TOptions;
#discriminatorKey: string | null = null;
diff --git a/libs/schema/src/builders/boundaries.test-d.ts b/libs/schema/src/builders/boundaries.test-d.ts
deleted file mode 100644
index f9f9ef39..00000000
--- a/libs/schema/src/builders/boundaries.test-d.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-import { expectTypeOf, test } from 'vitest';
-import {
- array,
- decode,
- type InferInput,
- type InferOutput,
- type InferType,
- intersection,
- lazy,
- number,
- object,
- record,
- schemaRef,
- string,
- tuple,
- union
-} from '../index.js';
-
-test('input/output inference composes without changing InferType', () => {
- const size = decode(string(), number(), Number);
- expectTypeOf>().toEqualTypeOf();
- expectTypeOf>().toEqualTypeOf();
- expectTypeOf>().toEqualTypeOf();
- expectTypeOf(size.parse('1')).toEqualTypeOf();
- const form = object({ size, title: string().default('untitled') });
- expectTypeOf>().toMatchTypeOf<{
- size: string;
- title?: string;
- }>();
- expectTypeOf<{ size: string }>().toMatchTypeOf>();
- expectTypeOf>().toMatchTypeOf<{
- size: number;
- title: string;
- }>();
- const list = array(size);
- expectTypeOf>().toEqualTypeOf();
- expectTypeOf>().toEqualTypeOf();
- const pair = tuple([size, string()]);
- expectTypeOf>().toEqualTypeOf<[string, string]>();
- const dictionary = record(string(), size);
- expectTypeOf>().toEqualTypeOf<
- Record
- >();
- const choice = union(size).or(string());
- expectTypeOf>().toEqualTypeOf();
- const joined = intersection(object({ size }), object({ title: string() }));
- expectTypeOf>().toMatchTypeOf<{
- size: string;
- title: string;
- }>();
- const ref = schemaRef(form.schemaName('Form'));
- expectTypeOf>().toEqualTypeOf<
- InferInput
- >();
- const deferred = lazy(() => size);
- expectTypeOf>().toEqualTypeOf();
- // @ts-expect-error required schemas cannot fall back to undefined
- string().catch(undefined);
- // @ts-expect-error non-nullable schemas cannot fall back to null
- string().catch(null);
- // @ts-expect-error converter must return the output schema's input
- decode(string(), number(), value => value);
- string().optional().catch(undefined);
- string()
- .nullable()
- .catch(() => null);
- string()
- .optional()
- .addPreprocessor(() => undefined);
-});
-
-test('boundary presence belongs to each side independently', () => {
- const nullableOutput = decode(string(), number().nullable(), () => null);
- expectTypeOf>().toEqualTypeOf();
- expectTypeOf>().toEqualTypeOf<
- number | null
- >();
- const nullableInput = decode(string().nullable(), number(), value =>
- value === null ? 0 : Number(value)
- );
- expectTypeOf>().toEqualTypeOf<
- string | null
- >();
- expectTypeOf>().toEqualTypeOf();
- const defaults = object({
- size: decode(string().default('1'), number(), Number)
- });
- expectTypeOf<{}>().toMatchTypeOf>();
- const optimized = defaults.optimize();
- expectTypeOf>().toEqualTypeOf<
- InferInput
- >();
- const required = nullableInput.optional().required().notNullable();
- expectTypeOf>().toEqualTypeOf();
- const ref = schemaRef(object({ name: string() }).schemaName('User'))
- .nullable()
- .optional();
- const root = object({ user: ref });
- root.validate({ user: null }).getErrorsFor(t => t.user.name);
- // @ts-expect-error concrete descriptor trees still reject unknown fields
- root.validate({ user: null }).getErrorsFor(t => t.user.missing);
-});
diff --git a/libs/schema/src/builders/references.test-d.ts b/libs/schema/src/builders/references.test-d.ts
new file mode 100644
index 00000000..1f8ca4a9
--- /dev/null
+++ b/libs/schema/src/builders/references.test-d.ts
@@ -0,0 +1,47 @@
+import { expectTypeOf, test } from 'vitest';
+import { type InferType, number, object, schemaRef, string } from '../index.js';
+
+test('optional-aware fallback and preprocessing types remain checked', () => {
+ // @ts-expect-error required schemas cannot fall back to undefined
+ string().catch(undefined);
+ // @ts-expect-error non-nullable schemas cannot fall back to null
+ string().catch(null);
+ string().optional().catch(undefined);
+ string()
+ .nullable()
+ .catch(() => null);
+ string()
+ .optional()
+ .addPreprocessor(() => undefined);
+ string()
+ .nullable()
+ .addPreprocessor(async () => null);
+});
+
+test('references preserve inferred types, local modifiers and selectors', () => {
+ const user = object({ name: string() }).schemaName('User');
+ const ref = schemaRef(user);
+ expectTypeOf>().toEqualTypeOf<
+ InferType
+ >();
+ const optional = ref.optional().nullable();
+ expectTypeOf>().toEqualTypeOf<
+ InferType | undefined | null
+ >();
+ const required = optional.required().notNullable();
+ expectTypeOf>().toEqualTypeOf<
+ InferType
+ >();
+ const withDefault = ref.optional().default({ name: 'Ada' });
+ expectTypeOf>().toEqualTypeOf<
+ InferType
+ >();
+ const root = object({ user: optional });
+ root.validate({ user: null }).getErrorsFor(t => t.user.name);
+ // @ts-expect-error concrete descriptor trees still reject unknown fields
+ root.validate({ user: null }).getErrorsFor(t => t.user.missing);
+ const override = schemaRef(number().schemaName('Number')).hasType();
+ expectTypeOf>().toEqualTypeOf();
+ const cleared = override.clearHasType();
+ expectTypeOf>().toEqualTypeOf();
+});
diff --git a/libs/schema/src/core.ts b/libs/schema/src/core.ts
index b0123534..c27c368c 100644
--- a/libs/schema/src/core.ts
+++ b/libs/schema/src/core.ts
@@ -12,11 +12,6 @@ export {
BooleanSchemaBuilder,
boolean
} from './builders/BooleanSchemaBuilder.js';
-export {
- BoundarySchemaBuilder,
- decode,
- schemaRef
-} from './builders/BoundarySchemaBuilder.js';
export { DateSchemaBuilder, date } from './builders/DateSchemaBuilder.js';
export {
ExternSchemaBuilder,
@@ -55,9 +50,11 @@ export {
} from './builders/PromiseSchemaBuilder.js';
export type { RecordSchemaValidationResult } from './builders/RecordSchemaBuilder.js';
export { RecordSchemaBuilder, record } from './builders/RecordSchemaBuilder.js';
+export {
+ ReferenceSchemaBuilder,
+ schemaRef
+} from './builders/ReferenceSchemaBuilder.js';
export type {
- InferInput,
- InferOutput,
NestedValidationResult,
PropertyDescriptor,
PropertyDescriptorInner,
diff --git a/libs/server-openapi/README.md b/libs/server-openapi/README.md
index 84426fe1..f44f40cd 100644
--- a/libs/server-openapi/README.md
+++ b/libs/server-openapi/README.md
@@ -9,15 +9,10 @@ OpenAPI 3.1 specification generation for [`@cleverbrush/server`](../server). Con
### Schema boundaries
-Requests use schema input views and responses use output views. A named schema
-with different representations gets collision-checked `NameInput` and
-`NameOutput` components; identical representations keep their original name.
-AsyncAPI uses input for incoming messages and output for outgoing messages.
-
Use `schemaRef(NamedSchema).optional().nullable().describe(...)` to annotate a
single use without cloning the named definition. Conflicting named definitions
-still fail. See the [composition guide](../schema/BOUNDARIES.md), including the
-separate adoption required for typed clients and editable form state.
+still fail. OpenAPI and AsyncAPI retain one canonical component per named target.
+See the [composition guide](../schema/BOUNDARIES.md).
- **`generateOpenApiSpec()`** — converts `@cleverbrush/server` endpoint registrations into an OpenAPI 3.1 document.
- **`generateAsyncApiSpec()`** — converts `@cleverbrush/server` WebSocket subscription registrations into an AsyncAPI 3.0 document.
diff --git a/libs/server-openapi/src/boundaries.test.ts b/libs/server-openapi/src/boundaries.test.ts
index 91421f28..28c0702d 100644
--- a/libs/server-openapi/src/boundaries.test.ts
+++ b/libs/server-openapi/src/boundaries.test.ts
@@ -1,8 +1,6 @@
import {
array,
- decode,
lazy,
- number,
object,
type SchemaBuilder,
schemaRef,
@@ -10,93 +8,117 @@ import {
} from '@cleverbrush/schema';
import { endpoint } from '@cleverbrush/server';
import { describe, expect, it } from 'vitest';
+import { generateAsyncApiSpec } from './generateAsyncApiSpec.js';
import { generateOpenApiSpec } from './generateOpenApiSpec.js';
import { SchemaRegistry, walkSchemas } from './schemaRegistry.js';
-describe('schema boundaries in OpenAPI', () => {
- it('uses input requests, output responses, and directional nested components', () => {
- const size = decode(string(), number().isInteger(), Number).schemaName(
- 'Size'
- );
- const request = object({ size: schemaRef(size) }).schemaName(
- 'Envelope'
- );
+describe('named schema references in API documents', () => {
+ it('uses one canonical component for requests, responses and annotated references', () => {
+ const user = object({ name: string() }).schemaName('User');
+ const history = object({
+ current: schemaRef(user),
+ previous: schemaRef(user).nullable().optional().describe('Previous')
+ });
const contract = endpoint
- .post('/items')
- .body(request)
- .responses({ 200: request });
+ .post('/history')
+ .body(history)
+ .responses({ 200: history });
const spec = generateOpenApiSpec({
registrations: [
{ endpoint: contract.introspect(), handler: () => {} }
],
- info: { title: 'Boundaries', version: '1' }
+ info: { title: 'References', version: '1' }
}) as any;
- expect(
- spec.paths['/items'].post.requestBody.content['application/json']
- .schema
- ).toEqual({ $ref: '#/components/schemas/EnvelopeInput' });
- expect(
- spec.paths['/items'].post.responses['200'].content[
+ expect(Object.keys(spec.components.schemas)).toEqual(['User']);
+ const request =
+ spec.paths['/history'].post.requestBody.content['application/json']
+ .schema;
+ const response =
+ spec.paths['/history'].post.responses['200'].content[
'application/json'
- ].schema
- ).toEqual({ $ref: '#/components/schemas/EnvelopeOutput' });
- expect(spec.components.schemas.SizeInput).toEqual({
- allOf: [{ type: 'string' }]
- });
- expect(spec.components.schemas.SizeOutput).toEqual({
- allOf: [{ type: 'integer' }]
+ ].schema;
+ expect(request).toEqual(response);
+ expect(request.required).toEqual(['current']);
+ expect(request.properties.current.allOf[0].$ref).toBe(
+ '#/components/schemas/User'
+ );
+ expect(request.properties.previous.description).toBe('Previous');
+ expect(spec.components.schemas.User).toMatchObject({
+ type: 'object',
+ required: ['name']
});
- expect(
- spec.components.schemas.EnvelopeInput.properties.size.allOf[0].$ref
- ).toBe('#/components/schemas/SizeInput');
});
- it('registers one target with independently annotated references', () => {
+ it('preserves strict instance-based naming conflicts', () => {
const user = object({ name: string() }).schemaName('User');
- const root = object({
- user,
- previous: schemaRef(user).nullable().optional().describe('Previous')
- });
const registry = new SchemaRegistry();
- walkSchemas(root, registry);
- expect(
- [...registry.directionalEntries()].map(([name]) => name)
- ).toEqual(['User']);
+ walkSchemas(
+ object({
+ current: user,
+ previous: schemaRef(user).nullable().optional()
+ }),
+ registry
+ );
+ expect([...registry.entries()].map(([name]) => name)).toEqual(['User']);
expect(() => walkSchemas(user.optional(), registry)).toThrow(
/already registered/
);
+ expect(() =>
+ walkSchemas(string().schemaName('User'), registry)
+ ).toThrow(/already registered/);
});
- it('rejects generated-name collisions regardless of registration order', () => {
- const size = decode(string(), number(), Number).schemaName('Size');
- const collision = string().schemaName('SizeInput');
- for (const schemas of [
- [size, collision],
- [collision, size]
- ]) {
- const registry = new SchemaRegistry();
- for (const schema of schemas) walkSchemas(schema, registry);
- expect(() => [...registry.directionalEntries()]).toThrow(
- /SizeInput/
- );
- }
- });
-
- it('terminates on named recursive references', () => {
+ it('terminates on named recursive references in OpenAPI and AsyncAPI', () => {
type Node = { name: string; children: Node[] };
const node: SchemaBuilder = object({
name: string(),
children: array(lazy(() => schemaRef(node)))
}).schemaName('Node');
const contract = endpoint.get('/nodes').responses({ 200: node });
- const spec = generateOpenApiSpec({
+ const openapi = generateOpenApiSpec({
registrations: [
{ endpoint: contract.introspect(), handler: () => {} }
],
info: { title: 'Nodes', version: '1' }
}) as any;
- expect(
- spec.components.schemas.Node.properties.children.items.allOf[0].$ref
- ).toBe('#/components/schemas/Node');
+ const asyncapi = generateAsyncApiSpec({
+ subscriptions: [
+ {
+ endpoint: {
+ protocol: 'subscription',
+ basePath: '/ws',
+ pathTemplate: '/nodes',
+ incomingSchema: schemaRef(node),
+ outgoingSchema: schemaRef(node),
+ querySchema: null,
+ headerSchema: null,
+ serviceSchemas: null,
+ authRoles: null,
+ summary: null,
+ description: null,
+ tags: [],
+ operationId: null,
+ deprecated: false,
+ externalDocs: null
+ },
+ handler: async function* () {}
+ }
+ ],
+ info: { title: 'Nodes', version: '1' }
+ }) as any;
+ for (const spec of [openapi, asyncapi]) {
+ expect(Object.keys(spec.components.schemas)).toEqual(['Node']);
+ expect(
+ spec.components.schemas.Node.properties.children.items.allOf[0]
+ .$ref
+ ).toBe('#/components/schemas/Node');
+ }
+ const channels = Object.values(asyncapi.channels) as any[];
+ expect(channels).toHaveLength(1);
+ expect(channels[0].address).toBe('/ws/nodes');
+ const messages = channels[0].messages;
+ expect(messages.ClientMessage.payload).toEqual(
+ messages.ServerEvent.payload
+ );
});
});
diff --git a/libs/server-openapi/src/generateAsyncApiSpec.ts b/libs/server-openapi/src/generateAsyncApiSpec.ts
index cdbf5fd1..9bf6c091 100644
--- a/libs/server-openapi/src/generateAsyncApiSpec.ts
+++ b/libs/server-openapi/src/generateAsyncApiSpec.ts
@@ -256,7 +256,7 @@ export function generateAsyncApiSpec(
messages['ClientMessage'] = {
name: 'ClientMessage',
...(inInfo.description ? { summary: inInfo.description } : {}),
- payload: convertSchema(m.incomingSchema, registry, 'input')
+ payload: convertSchema(m.incomingSchema, registry)
};
}
@@ -325,19 +325,15 @@ export function generateAsyncApiSpec(
if (!registry.isEmpty) {
const schemas: Record = {};
- for (const [name, schema, mode] of registry.directionalEntries()) {
+ for (const [name, schema] of registry.entries()) {
let rootInlined = false;
- schemas[name] = convertSchema(
- schema,
- candidate => {
- if (candidate === schema && !rootInlined) {
- rootInlined = true;
- return null;
- }
- return registry.getName(candidate, mode);
- },
- mode
- );
+ schemas[name] = convertSchema(schema, candidate => {
+ if (candidate === schema && !rootInlined) {
+ rootInlined = true;
+ return null;
+ }
+ return registry.getName(candidate);
+ });
}
doc.components = { schemas };
}
diff --git a/libs/server-openapi/src/generateOpenApiSpec.ts b/libs/server-openapi/src/generateOpenApiSpec.ts
index 352722cb..f566eb3e 100644
--- a/libs/server-openapi/src/generateOpenApiSpec.ts
+++ b/libs/server-openapi/src/generateOpenApiSpec.ts
@@ -161,16 +161,6 @@ function buildParameterObject(
return param;
}
-/** Determines omission on the request side without executing conversions. */
-function isInputRequired(schema: SchemaBuilder): boolean {
- const info = schema.introspect() as any;
- if (info.hasDefault) return false;
- if (info.type === 'reference' || info.type === 'decode') {
- return info.presence ?? isInputRequired(info.inputSchema);
- }
- return info.isRequired !== false;
-}
-
function buildRequestBody(
bodySchema: SchemaBuilder,
registry: SchemaRegistry,
@@ -183,18 +173,18 @@ function buildRequestBody(
): Record {
const bodyInfo = bodySchema.introspect() as any;
const body: Record = {
- required: isInputRequired(bodySchema)
+ required: bodyInfo.isRequired !== false
};
// When file uploads are enabled, emit multipart/form-data
if (fileUpload) {
- const jsonSchema = convertSchema(bodySchema, registry, 'input');
+ const jsonSchema = convertSchema(bodySchema, registry);
const mediaType: Record = { schema: jsonSchema };
body['content'] = {
'multipart/form-data': mediaType
};
} else {
- const jsonSchema = convertSchema(bodySchema, registry, 'input');
+ const jsonSchema = convertSchema(bodySchema, registry);
const mediaType: Record = { schema: jsonSchema };
if (example != null) {
mediaType['example'] = example;
@@ -491,7 +481,7 @@ function buildOperation(
> = queryInfo.properties ?? {};
for (const [name, propSchema] of Object.entries(props)) {
const propInfo = propSchema.introspect() as any;
- const isRequired = isInputRequired(propSchema);
+ const isRequired = propInfo.isRequired !== false;
const description =
typeof propInfo.description === 'string' &&
propInfo.description !== ''
@@ -501,7 +491,7 @@ function buildOperation(
buildParameterObject(
name,
'query',
- convertSchema(propSchema, registry, 'input'),
+ convertSchema(propSchema, registry),
isRequired,
description
)
@@ -518,7 +508,7 @@ function buildOperation(
> = headerInfo.properties ?? {};
for (const [name, propSchema] of Object.entries(props)) {
const propInfo = propSchema.introspect() as any;
- const isRequired = isInputRequired(propSchema);
+ const isRequired = propInfo.isRequired !== false;
const description =
typeof propInfo.description === 'string' &&
propInfo.description !== ''
@@ -528,7 +518,7 @@ function buildOperation(
buildParameterObject(
name,
'header',
- convertSchema(propSchema, registry, 'input'),
+ convertSchema(propSchema, registry),
isRequired,
description
)
@@ -677,13 +667,6 @@ export function generateOpenApiSpec(options: OpenApiOptions): OpenApiDocument {
if (meta.bodySchema) walkSchemas(meta.bodySchema, registry, visited);
if (meta.responseSchema)
walkSchemas(meta.responseSchema, registry, visited);
- if (meta.responseHeaderSchema)
- walkSchemas(meta.responseHeaderSchema, registry, visited);
- if (meta.produces) {
- for (const entry of Object.values(meta.produces)) {
- if (entry.schema) walkSchemas(entry.schema, registry, visited);
- }
- }
if (meta.responsesSchemas) {
for (const schema of Object.values(meta.responsesSchemas)) {
if (schema) walkSchemas(schema, registry, visited);
@@ -724,8 +707,7 @@ export function generateOpenApiSpec(options: OpenApiOptions): OpenApiDocument {
}
const resolveComponentSchemaName = (
- rootSchema: SchemaBuilder,
- mode: 'input' | 'output'
+ rootSchema: SchemaBuilder
) => {
// The root schema must be inlined exactly once — for the component
// definition itself. Any subsequent encounter (e.g. through a lazy
@@ -739,7 +721,7 @@ export function generateOpenApiSpec(options: OpenApiOptions): OpenApiDocument {
inlinedRoot = true;
return undefined; // inline the root definition once
}
- return registry.getName(candidate, mode) ?? undefined;
+ return registry.getName(candidate) ?? undefined;
};
};
@@ -831,14 +813,13 @@ export function generateOpenApiSpec(options: OpenApiOptions): OpenApiDocument {
// Components — security schemes + named component schemas
const componentSchemas: Record = {};
- for (const [name, schema, mode] of registry.directionalEntries()) {
+ for (const [name, schema] of registry.entries()) {
// Inline the root schema to avoid a self-referential $ref, but resolve
// nested named schemas through the shared registry so component
// definitions can still deduplicate via $ref.
componentSchemas[name] = convertSchema(
schema,
- resolveComponentSchemaName(schema, mode),
- mode
+ resolveComponentSchemaName(schema)
);
}
const hasSchemas = Object.keys(componentSchemas).length > 0;
diff --git a/libs/server-openapi/src/schemaConverter.ts b/libs/server-openapi/src/schemaConverter.ts
index 817c0219..c0e83dfc 100644
--- a/libs/server-openapi/src/schemaConverter.ts
+++ b/libs/server-openapi/src/schemaConverter.ts
@@ -18,12 +18,10 @@ type NameResolver = (
*
* @param schema - The schema to convert, or `null`/`undefined`.
* @param registry - Optional registry or resolver function for `$ref` deduplication.
- * @param mode - Input before decoding/defaults, or validated output (default).
*/
export function convertSchema(
schema: SchemaBuilder | null | undefined,
- registry?: SchemaRegistry | NameResolver,
- mode: 'input' | 'output' = 'output'
+ registry?: SchemaRegistry | NameResolver
): Record {
if (schema == null) return {};
@@ -34,14 +32,13 @@ export function convertSchema(
if (typeof registry === 'function') {
nameResolver = s => registry(s) ?? null;
} else {
- nameResolver = s => registry.getName(s, mode);
+ nameResolver = s => registry.getName(s);
}
}
return toJsonSchema(schema, {
$schema: false,
draft: '2020-12',
- nameResolver,
- mode
+ nameResolver
});
}
diff --git a/libs/server-openapi/src/schemaRegistry.ts b/libs/server-openapi/src/schemaRegistry.ts
index b934a656..f2610a17 100644
--- a/libs/server-openapi/src/schemaRegistry.ts
+++ b/libs/server-openapi/src/schemaRegistry.ts
@@ -1,5 +1,4 @@
import type { SchemaBuilder } from '@cleverbrush/schema';
-import { toJsonSchema } from '@cleverbrush/schema-json';
// ---------------------------------------------------------------------------
// SchemaRegistry
@@ -31,9 +30,6 @@ export class SchemaRegistry {
>();
/** name → first-registered schema instance */
private readonly byName = new Map>();
- private directions:
- | Map, { input: string; output: string }>
- | undefined;
/**
* Attempts to register `schema` in the registry.
@@ -68,7 +64,6 @@ export class SchemaRegistry {
this.byInstance.set(schema, name);
this.byName.set(name, schema);
- this.directions = undefined;
}
/**
@@ -78,93 +73,8 @@ export class SchemaRegistry {
* @param schema - The schema builder to look up.
* @returns The registered name, or `null`.
*/
- getName(
- schema: SchemaBuilder,
- mode: 'input' | 'output' = 'output'
- ): string | null {
- if (!this.byInstance.has(schema)) return null;
- this.prepareDirections();
- return this.directions!.get(schema)![mode];
- }
-
- /**
- * Emits independent input/output definitions only when their declared
- * representations differ. Derived names are collision checked.
- * @throws Error when a generated component name is already reserved.
- */
- *directionalEntries(): IterableIterator<
- [string, SchemaBuilder, 'input' | 'output']
- > {
- this.prepareDirections();
- for (const [schema, names] of this.directions!) {
- if (names.input !== names.output)
- yield [names.input, schema, 'input'];
- yield [names.output, schema, 'output'];
- }
- }
-
- private prepareDirections(): void {
- if (this.directions) return;
- const different = new Set>();
- // A changed named child changes its parents' references. Iterate to a
- // fixed point; named recursive edges are always references, not recursion.
- let changed = true;
- while (changed) {
- changed = false;
- for (const [, schema] of this.byName) {
- if (different.has(schema)) continue;
- const render = (mode: 'input' | 'output') => {
- let rootInlined = false;
- return JSON.stringify(
- toJsonSchema(schema, {
- $schema: false,
- mode,
- nameResolver: candidate => {
- if (candidate === schema && !rootInlined) {
- rootInlined = true;
- return null;
- }
- const name = this.byInstance.get(candidate);
- return name
- ? name +
- (different.has(candidate)
- ? mode === 'input'
- ? 'Input'
- : 'Output'
- : '')
- : null;
- }
- })
- );
- };
- if (render('input') !== render('output')) {
- different.add(schema);
- changed = true;
- }
- }
- }
- const names = new Map>(
- this.byName
- );
- const directions = new Map<
- SchemaBuilder,
- { input: string; output: string }
- >();
- for (const [name, schema] of this.byName) {
- const pair = different.has(schema)
- ? { input: name + 'Input', output: name + 'Output' }
- : { input: name, output: name };
- for (const derived of new Set(Object.values(pair))) {
- if (names.has(derived) && names.get(derived) !== schema) {
- throw new Error(
- `Schema component name "${derived}" conflicts with a generated input/output name.`
- );
- }
- names.set(derived, schema);
- }
- directions.set(schema, pair);
- }
- this.directions = directions;
+ getName(schema: SchemaBuilder): string | null {
+ return this.byInstance.get(schema) ?? null;
}
/**
@@ -217,9 +127,7 @@ export function walkSchemas(
switch (info.type) {
case 'reference':
- case 'decode':
- walkSchemas(info.inputSchema, registry, visited);
- walkSchemas(info.outputSchema, registry, visited);
+ walkSchemas(info.targetSchema, registry, visited);
break;
case 'intersection':
walkSchemas(info.left, registry, visited);
diff --git a/websites/docs/app/server-openapi/page.tsx b/websites/docs/app/server-openapi/page.tsx
index 0d96da94..784a18fb 100644
--- a/websites/docs/app/server-openapi/page.tsx
+++ b/websites/docs/app/server-openapi/page.tsx
@@ -21,13 +21,13 @@ export default function ServerOpenApiPage() {