Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/schema-boundaries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@cleverbrush/schema": minor
"@cleverbrush/schema-json": minor
"@cleverbrush/server-openapi": minor
---

Add optional-aware fallbacks and preprocessing, and automatic named schema
references through ordinary immutable use-site modifiers, without a wrapper API.
Shape, validation-rule, default, fallback and extension changes clear inherited
names; apply schemaName after those edits to establish a new named definition.
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.
36 changes: 35 additions & 1 deletion libs/schema-json/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,40 @@ Descriptions set via `.describe(text)` are emitted as the `description` field on

Examples set via `.example(value)` are emitted as the `examples` array on the corresponding JSON Schema node.

#### Named references and local annotations

Ordinary modifiers such as `User.optional().nullable().describe('Previous')`
preserve the canonical named definition. With a `nameResolver`, direct reuse
emits a `$ref`; modified uses compose around that reference so annotations and
nullability remain local in both Draft 07 and Draft 2020-12.

```ts
const User = object({ name: string() }).schemaName('User');
const History = object({
current: User,
previous: User.optional().nullable().describe('Previous user')
});
const json = toJsonSchema(History, {
$schema: false,
nameResolver: schema => schema.introspect().schemaName ?? null
});
// json.required: ['current']
// json.properties.current: { $ref: '#/components/schemas/User' }
// json.properties.previous: local description and a nullable reference to User
```

Use-site modifiers are handled before name resolution, which receives the
canonical target. The resolver does not create component definitions itself;
supply them in the containing document or use `@cleverbrush/server-openapi`.
Without a resolver, the target is converted inline.

Shape, validation-rule, default, fallback and extension changes discard the
inherited name and export inline, while nested named children still reuse their
definitions. Apply `schemaName` after such edits to name a new definition. See
the [schema naming rules](https://schema.cleverbrush.com/docs/schema-modifiers#schema-name).
Standard JSON Schema `input()` and `output()` retain their existing identical
representation; no separate directional schemas are introduced.

#### Discriminated unions

When a `union()` is a **discriminated union** — all branches are objects sharing a required property with unique literal values — `toJsonSchema()` automatically emits the `discriminator` keyword alongside `anyOf`:
Expand Down Expand Up @@ -188,7 +222,7 @@ This enables code-generation tools (openapi-generator, orval, etc.) to produce p
| --- | --- | --- | --- |
| `draft` | `'2020-12' \| '07'` | `'2020-12'` | JSON Schema draft version for the `$schema` URI |
| `$schema` | `boolean` | `true` | Whether to include the `$schema` header in the output |
| `nameResolver` | `(schema: SchemaBuilder) => string \| null` | `undefined` | Called for every node before conversion. Return a non-null string to emit `{ $ref: '#/components/schemas/<name>' }` instead of an inline schema. Used by `@cleverbrush/server-openapi` to wire named schemas from `.schemaName()` into `$ref` pointers. |
| `nameResolver` | `(schema: SchemaBuilder) => string \| null` | `undefined` | Return a component name to emit `{ $ref: '#/components/schemas/<name>' }` instead of an inline definition. Use-site modifiers resolve their canonical target and compose local annotations/nullability around it. Used by `@cleverbrush/server-openapi` for named components. |

```ts
// Embed in OpenAPI (suppress the $schema header)
Expand Down
123 changes: 123 additions & 0 deletions libs/schema-json/src/boundaries.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { object, string } from '@cleverbrush/schema';
import { describe, expect, it } from 'vitest';
import { withStandardJsonSchema } from './standardJsonSchema.js';
import { toJsonSchema } from './toJsonSchema.js';

describe('named reference JSON Schema', () => {
it.each([
'2020-12',
'07'
] as const)('keeps annotations outside the definition in draft %s', draft => {
const user = object({ name: string() }).schemaName('User');
const schema = object({
user: user,
previous: 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({
$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('keeps final local default and nullability modifiers', () => {
const target = string().nullable().schemaName('Name');
const ref = 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).toBeUndefined();
expect(json.properties.name.default).toBe('new');
expect(json.properties.name.type).toBe('string');
expect(json.properties.name.allOf).toBeUndefined();
const nullableAgain = toJsonSchema(ref.nullable());
expect(nullableAgain.type).toEqual(['string', 'null']);
expect(toJsonSchema(ref.readonly()).readOnly).toBe(true);
});

it('retains identical Standard JSON Schema views', () => {
const ref = 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' })
);
});

it.each([
'2020-12',
'07'
] as const)('retains local modifiers before name resolution in draft %s', draft => {
const target = string()
.nullable()
.describe('Canonical')
.schemaName('Name');
const local = target
.notNullable()
.describe('Use')
.example('Ada')
.readonly();
const nameResolver = (s: typeof target) =>
s.introspect().schemaName ?? null;
expect(
toJsonSchema(local, { draft, $schema: false, nameResolver })
).toEqual({
allOf: [
{ $ref: '#/components/schemas/Name' },
{ not: { type: 'null' } }
],
description: 'Use',
examples: ['Ada'],
readOnly: true
});
expect(toJsonSchema(local, { draft, $schema: false }).allOf).toEqual([
{ type: ['string', 'null'], description: 'Canonical' },
{ not: { type: 'null' } }
]);
expect(
toJsonSchema(local.nullable(), {
draft,
$schema: false,
nameResolver
}).allOf
).toEqual([{ $ref: '#/components/schemas/Name' }]);
expect(target.introspect().description).toBe('Canonical');
});

it('exports shape/rule derivatives inline while keeping nested named children', () => {
const name = string().schemaName('Name');
const user = object({ name }).schemaName('User');
const partial = user.partial().describe('Patch');
const json = toJsonSchema(partial, {
$schema: false,
nameResolver: s => s.introspect().schemaName ?? null
}) as any;
expect(json.type).toBe('object');
expect(json.required).toBeUndefined();
expect(json.properties.name.allOf).toEqual([
{ $ref: '#/components/schemas/Name' }
]);
expect(
toJsonSchema(name.maxLength(3), {
$schema: false,
nameResolver: s => s.introspect().schemaName ?? null
})
).toEqual({ type: 'string', maxLength: 3 });
});
});
17 changes: 16 additions & 1 deletion libs/schema-json/src/toJsonSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,22 @@ function convertNode(
schema: SchemaBuilder<any, any, any>,
resolver: Resolver
): Out {
const info = schema.introspect();
if (info.referenceTarget) {
const target = info.referenceTarget;
const canonical = target.introspect();
// Handle aliases before name lookup so use-site modifiers survive.
let out: Out = { allOf: [convertNode(target, resolver)] };
if (info.isNullable && !canonical.isNullable) {
out = { anyOf: [out, { type: 'null' }] };
} else if (!info.isNullable && canonical.isNullable) {
(out.allOf as Out[]).push({ not: { type: 'null' } });
}
if (info.description !== undefined) out.description = info.description;
if (info.example !== undefined) out.examples = [info.example];
if (info.isReadonly) out.readOnly = true;
return out;
}
if (resolver) {
const name = resolver(schema);
if (typeof name === 'string' && name.length > 0) {
Expand All @@ -251,7 +267,6 @@ function convertNode(
}
}
const out = convertNodeInner(schema, resolver);
const info = schema.introspect() as any;
if (typeof info.description === 'string' && info.description !== '')
out['description'] = info.description;

Expand Down
95 changes: 92 additions & 3 deletions libs/schema/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1125,6 +1125,43 @@ console.log(info.hasCatch); // true
console.log(info.catchValue); // 'unknown'
```

### Optional and nullable fallbacks

Fallback values and factories respect the schema's resolved output type:
optional schemas allow `undefined`, and nullable schemas allow `null`.

```typescript
const optionalText = string().optional().catch(undefined);
const nullableText = string().nullable().catch(() => null);

optionalText.parse(42); // undefined
nullableText.parse(42); // null
object({ text: optionalText }).parse({ text: false }); // { text: undefined }
array(optionalText).parse(['ok', 42]); // ['ok', undefined] — no entries dropped
```

Fallbacks are opt-in: a fallback on a property does not make a malformed required
root object valid. A fallback factory runs only when validation fails.

**Null compatibility:** legacy optional schemas accept `null` at runtime even
though their inferred type does not include it. `.optional().catch(undefined)`
therefore leaves `null` unchanged. Normalize it explicitly when needed:

```typescript
const normalizedText = string().optional()
.addPreprocessor(value => value == null ? undefined : value)
.catch(undefined);

normalizedText.parse(null); // undefined
```

Preprocessors can return optional/nullable values, including asynchronously.
Their existing callback parameter typing does not guarantee that unknown input
already has that type: guard untrusted values before using type-specific methods.
Use `parseAsync` / `validateAsync` for async preprocessors or validators.
`InferType` and `hasType` keep their existing meaning; static overrides and casts
do not perform runtime conversion or validation.

## Readonly Modifier

Every schema builder supports `.readonly()`. This is a **type-level-only** modifier — it marks the inferred TypeScript type as immutable, but does not alter validation behaviour or freeze the validated value at runtime.
Expand Down Expand Up @@ -1189,7 +1226,7 @@ export const UserSchema = object({
UserSchema.introspect().schemaName; // 'User'
```

Chains naturally with all other modifiers:
Annotations can be applied after naming a definition:

```typescript
const ProductSchema = object({
Expand All @@ -1210,12 +1247,64 @@ import { generateOpenApiSpec } from '@cleverbrush/server-openapi';
generateOpenApiSpec({ registrations, info: { title: 'My API', version: '1.0.0' } });
```

> **Name uniqueness:** Registering two *different* schema instances under the same name throws an error. Always export named schemas as constants and reuse the same reference everywhere.
### Reusing a named definition

Use the plain constant directly, or apply ordinary use-site modifiers. No wrapper
is needed; the concrete builder, fluent and extension methods, inferred types,
and nested property selectors are preserved. The original remains unchanged.

```typescript
const History = object({
current: UserSchema,
previous: UserSchema.optional().nullable().describe('Previous user')
});
// One canonical User component; previous has local annotations/nullability.
```

These modifiers retain the canonical named definition for document exporters:

- Presence/nullability: `optional`, `required`, `nullable`, `notNullable`.
- Annotations: `describe`, `example`, `readonly`.
- Type-only changes: `brand`, `hasType`, `clearHasType`, `optimize`.

Modifier chains reference the original definition, not another alias. JSON Schema,
OpenAPI and AsyncAPI compose local annotations and nullability around that
definition, including Draft 07 references. Runtime validation still follows the
ordinary builder's behavior; canonical-reference metadata is for exporters.

### Shape and rule changes discard inherited names

Property additions/removals, `partial`, `pick`, `omit`, constraints, validators,
preprocessors, defaults, fallbacks and their available clear methods produce
unnamed derivatives. Extension changes detach conservatively too. Later
annotations or optionality do not reconnect the derivative to the original.

```typescript
const PatchUser = UserSchema.partial(); // unnamed, changed shape
const UserWithEmail = UserSchema.addProp('email', string()); // unnamed
const PublicUser = UserSchema.omit('id').schemaName('PublicUser'); // new definition
const ShortName = string().schemaName('Name').maxLength(20); // unnamed rule change
```

Existing nested named schemas still reuse their own definitions. Apply
`schemaName` **after** shape/rule edits when the result needs a stable component
name. This changes inherited-name behavior: code that relied on property or
constraint edits retaining a name should name the final result explicitly.

Defaults and fallbacks keep ordinary builder semantics. Adding or clearing them
detaches the inherited name; `clearDefault()` removes the default completely,
without revealing a hidden default from the canonical definition.

> **Name uniqueness:** Independent definitions with the same name still conflict,
> even with identical shapes; there is no name-only or structural deduplication.
> Use-site modifiers reuse the original definition and do not conflict. Calling
> `schemaName` explicitly always establishes a fresh independent definition,
> even on an alias or when the previous name is reused.

| Method / Property | Signature | Notes |
|---|---|---|
| `.schemaName(name)` | `schemaName(name: string): this` | Returns a new builder; original is unchanged |
| `.introspect().schemaName` | `string \| undefined` | The name passed to `.schemaName()`, or `undefined` |
| `.introspect().schemaName` | `string \| undefined` | The explicit or preserved name; `undefined` after a shape/rule change |

## Describe

Expand Down
18 changes: 12 additions & 6 deletions libs/schema/src/builders/AnySchemaBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,12 @@ export class AnySchemaBuilder<
_notUsed?: T
): AnySchemaBuilder<true, TNullable, T, THasDefault, TExtensions> &
TExtensions {
return this.createFromProps({
...this.introspect()
} as any) as any;
return this.derive(
{
...this.introspect()
} as any,
true
) as any;
}

/**
Expand All @@ -78,9 +81,12 @@ export class AnySchemaBuilder<
TExtensions
> &
TExtensions {
return this.createFromProps({
...this.introspect()
} as any) as any;
return this.derive(
{
...this.introspect()
} as any,
true
) as any;
}

#buildResult(
Expand Down
Loading
Loading