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
11 changes: 11 additions & 0 deletions .changeset/tiny-dragons-shout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@cleverbrush/schema': minor
'@cleverbrush/schema-json': minor
---

Add `intersection()` schema builder for combining two schemas (both must pass)

- New `IntersectionSchemaBuilder` class with `intersection(left, right)` factory
- Validates both schemas against the input and merges outputs
- Maps to `allOf` in JSON Schema (to/from bidirectional)
- Supports all standard modifiers: `.optional()`, `.nullable()`, `.default()`, `.catch()`, `.brand()`, `.readonly()`, `.addValidator()`, `.addPreprocessor()`, etc.
3 changes: 1 addition & 2 deletions libs/schema-json/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ const schema = fromJsonSchema(S); // ObjectSchemaBuilder<{ x: NumberSchemaBuilde
| `enum` | `union(…)` of const builders |
| `anyOf` | `union(…)` of sub-builders |
| `anyOf` + `discriminator` | auto-emitted for discriminated `union()` branches (see below) |
| `allOf` | not supported — falls back to `any()` |
| `allOf` | `intersection(...)` — chains sub-schemas via the `intersection()` builder |
| `minLength` / `maxLength` | `.minLength()` / `.maxLength()` |
| `pattern` | `.matches(regex)` (invalid patterns silently ignored) |
| `minimum` / `maximum` | `.min()` / `.max()` |
Expand Down Expand Up @@ -288,7 +288,6 @@ type B = JsonSchemaNodeToBuilder<typeof S>;
| `$ref` / `$defs` | Not supported in `fromJsonSchema` |
| `if` / `then` / `else` | Not supported |
| `not` | Not supported |
| `allOf` in `fromJsonSchema` | Falls back to `SchemaBuilder<unknown>` (no deep merge) |
| Dual IP format (`ip()` with both v4 + v6) | `format` is omitted in `toJsonSchema` output (no standard keyword covers both) |
| JSDoc comments on properties | Not preserved in `toJsonSchema` output |
| `nameResolver` + `$ref` / `$defs` round-trip | `nameResolver` emits `$ref` pointers based on external registry; `fromJsonSchema` does not resolve `$ref` references — they fall back to `any()` |
97 changes: 90 additions & 7 deletions libs/schema-json/src/fromJsonSchema.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { InferType } from '@cleverbrush/schema';
import type { InferType, IntersectionSchemaBuilder } from '@cleverbrush/schema';
import { expect, expectTypeOf, test } from 'vitest';
import { fromJsonSchema } from './fromJsonSchema.js';

Expand Down Expand Up @@ -319,15 +319,98 @@ test('fromJsonSchema - 28: anyOf accepts either type', () => {
expect(valid(schema, true)).toBe(false);
});

test('fromJsonSchema - 28b: allOf falls back to any() (not supported)', () => {
test('fromJsonSchema - 28b: allOf maps to intersection', () => {
const schema = fromJsonSchema({
allOf: [{ type: 'string' }, { minLength: 1 }]
allOf: [
{ type: 'string', minLength: 1 },
{ type: 'string', maxLength: 10 }
]
} as const);
// allOf is not supported; falls back to any() which accepts anything
expectTypeOf<InferType<typeof schema>>().toMatchTypeOf<unknown>();

// Type-level: InferType<typeof schema> should be string (intersection of string & string)
expectTypeOf<InferType<typeof schema>>().toMatchTypeOf<string>();

// Type-level: should NOT be unknown
expectTypeOf<InferType<typeof schema>>().not.toMatchTypeOf<unknown>();

// Type-level: schema should be an IntersectionSchemaBuilder
expectTypeOf<typeof schema>().toMatchTypeOf<
IntersectionSchemaBuilder<any, any>
>();

expect(valid(schema, 'hello')).toBe(true);
expect(valid(schema, 42)).toBe(true);
expect(valid(schema, null)).toBe(false);
expect(valid(schema, '')).toBe(false);
expect(valid(schema, 'a'.repeat(11))).toBe(false);
});

test('fromJsonSchema - 28c: allOf type is not unknown', () => {
const schema = fromJsonSchema({
allOf: [
{
type: 'object',
properties: { name: { type: 'string' } },
required: ['name']
},
{
type: 'object',
properties: { age: { type: 'number' } },
required: ['age']
}
]
} as const);

// InferType should be { name: string } & { age: number }, not unknown
expectTypeOf<InferType<typeof schema>>().toMatchTypeOf<{
name: string;
age: number;
}>();
expectTypeOf<InferType<typeof schema>>().not.toMatchTypeOf<unknown>();
});

test('fromJsonSchema - 28d: allOf three elements produces intersection type', () => {
const schema = fromJsonSchema({
allOf: [
{
type: 'object',
properties: { name: { type: 'string' } },
required: ['name']
},
{
type: 'object',
properties: { age: { type: 'number' } },
required: ['age']
},
{
type: 'object',
properties: { email: { type: 'string' } },
required: ['email']
}
]
} as const);

expectTypeOf<InferType<typeof schema>>().toMatchTypeOf<{
name: string;
age: number;
email: string;
}>();
expectTypeOf<InferType<typeof schema>>().not.toMatchTypeOf<unknown>();
});

test('fromJsonSchema - 28e: allOf single element returns direct builder type', () => {
const schema = fromJsonSchema({
allOf: [{ type: 'string', minLength: 1 }]
} as const);

expectTypeOf<InferType<typeof schema>>().toMatchTypeOf<string>();
expectTypeOf<InferType<typeof schema>>().not.toMatchTypeOf<unknown>();
});

test('fromJsonSchema - 28f: allOf empty returns unknown builder', () => {
const schema = fromJsonSchema({
allOf: []
} as const);

expectTypeOf<InferType<typeof schema>>().toMatchTypeOf<unknown>();
});

// ---------------------------------------------------------------------------
Expand Down
19 changes: 14 additions & 5 deletions libs/schema-json/src/fromJsonSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
any,
array,
boolean,
intersection,
nul,
number,
object,
Expand Down Expand Up @@ -35,8 +36,8 @@ function buildNode(s: unknown): SchemaBuilder<any, any, any> {
else if ('const' in node) b = buildConst(node['const']);
else if ('anyOf' in node && Array.isArray(node['anyOf']))
b = buildAnyOf(node['anyOf']);
// allOf is not supported (no intersection builder); fall back to any()
else if ('allOf' in node && Array.isArray(node['allOf'])) b = any();
else if ('allOf' in node && Array.isArray(node['allOf']))
b = buildAllOf(node['allOf']);
else if (!('type' in node)) b = any();
else {
switch (node['type']) {
Expand Down Expand Up @@ -210,6 +211,15 @@ function buildAnyOf(options: unknown[]): SchemaBuilder<any, any, any> {
return b;
}

function buildAllOf(options: unknown[]): SchemaBuilder<any, any, any> {
if (options.length === 0) return any();
let b: any = buildNode(options[0]);
for (let i = 1; i < options.length; i++) {
b = intersection(b, buildNode(options[i]));
}
return b;
}

/**
* Converts a JSON Schema object into a `@cleverbrush/schema` builder.
*
Expand Down Expand Up @@ -248,9 +258,8 @@ function buildAnyOf(options: unknown[]): SchemaBuilder<any, any, any> {
* | `format: 'ipv6'` | `.ip({ version: 'v6' })` |
* | `format: 'date-time'` | `.matches(iso8601 regex)` |
*
* Keywords **not** supported: `allOf` (falls back to `any()`), `$ref`,
* `$defs`, `if/then/else`, `not`, `contains`, `unevaluatedProperties`,
* `contentEncoding`.
* Keywords **not** supported: `$ref`, `$defs`, `if/then/else`, `not`,
* `contains`, `unevaluatedProperties`, `contentEncoding`.
*
* @param schema - A JSON Schema literal. Pass with `as const` for precise
* TypeScript type inference on the returned builder.
Expand Down
55 changes: 55 additions & 0 deletions libs/schema-json/src/toJsonSchema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
array,
boolean,
date,
intersection,
lazy,
nul,
number,
Expand Down Expand Up @@ -306,6 +307,60 @@ test('toJsonSchema - 28d: discriminated union with nameResolver → mapping', ()
expect(anyOf[1]).toEqual({ $ref: '#/components/schemas/Dog' });
});

test('toJsonSchema - intersection of objects → allOf', () => {
const result = toJsonSchema(
intersection(object({ name: string() }), object({ age: number() })),
{ $schema: false }
);
expect(result).toEqual({
allOf: [
{
type: 'object',
additionalProperties: false,
properties: { name: { type: 'string' } },
required: ['name']
},
{
type: 'object',
additionalProperties: false,
properties: { age: { type: 'integer' } },
required: ['age']
}
]
});
});

test('toJsonSchema - intersection nullable → oneOf with null', () => {
const result = toJsonSchema(
intersection(
object({ name: string() }),
object({ age: number() })
).nullable(),
{ $schema: false }
);
expect(result).toEqual({
oneOf: [
{
allOf: [
{
type: 'object',
additionalProperties: false,
properties: { name: { type: 'string' } },
required: ['name']
},
{
type: 'object',
additionalProperties: false,
properties: { age: { type: 'integer' } },
required: ['age']
}
]
},
{ type: 'null' }
]
});
});

// ---------------------------------------------------------------------------
// any
// ---------------------------------------------------------------------------
Expand Down
16 changes: 16 additions & 0 deletions libs/schema-json/src/toJsonSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,18 @@ function convertNodeInner(
return out;
}

case 'intersection': {
const left = info.left as SchemaBuilder<any, any, any>;
const right = info.right as SchemaBuilder<any, any, any>;
return {
...readOnly,
allOf: [
convertNode(left, resolver),
convertNode(right, resolver)
]
};
}

case 'lazy': {
// Resolve the lazy schema once and delegate conversion.
// If the resolved schema has a name registered in the nameResolver,
Expand Down Expand Up @@ -264,6 +276,10 @@ function convertNode(
const anyOf = out['anyOf'] as Out[];
const hasNull = anyOf.some(o => o['type'] === 'null');
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' }];
delete out['allOf'];
} else if (out['enum'] !== undefined) {
// Enum — add null to enum values if not already present
const enumValues = out['enum'] as unknown[];
Expand Down
67 changes: 62 additions & 5 deletions libs/schema-json/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
ExtendedArray,
ExtendedNumber,
ExtendedString,
IntersectionSchemaBuilder,
ObjectSchemaBuilder,
SchemaBuilder,
UnionSchemaBuilder
Expand Down Expand Up @@ -120,9 +121,18 @@ export type InferFromJsonSchema<S> = S extends { readonly const: infer V }
: S extends { readonly anyOf: readonly (infer U)[] }
? InferFromJsonSchema<U>
: S extends {
readonly allOf: readonly JsonSchemaNode[];
readonly allOf: readonly [
infer First extends JsonSchemaNode,
...infer Rest extends
readonly JsonSchemaNode[]
];
}
? unknown
? Rest extends readonly []
? InferFromJsonSchema<First>
: InferFromJsonSchema<First> &
InferFromJsonSchema<{
readonly allOf: Rest;
}>
: unknown;

/** Options accepted by {@link toJsonSchema}. */
Expand Down Expand Up @@ -274,6 +284,53 @@ type ObjectPropertiesToBuilders<
>;
};

/**
* When folding an allOf tuple, some accumulator elements may already be
* resolved builders from previous fold steps. This helper returns the
* input unchanged when it is already a schema builder, and otherwise
* maps it through {@link JsonSchemaNodeToBuilder}.
* @internal
*/
type AsBuilderIfNeeded<X, R extends boolean = true> =
X extends SchemaBuilder<any, any, any, any, any>
? X
: JsonSchemaNodeToBuilder<X, R>;

/**
* Recursively folds an `allOf` tuple left-to-right into a nested
* {@link IntersectionSchemaBuilder} chain, matching the runtime behavior
* of {@link fromJsonSchema}.
*
* @internal
*/
type AllOfNodesToBuilder<
Acc extends readonly unknown[],
TRequired extends boolean = true
> = Acc extends readonly [
infer First,
infer Second,
...infer Rest extends readonly unknown[]
]
? Rest extends readonly []
? IntersectionSchemaBuilder<
AsBuilderIfNeeded<First>,
AsBuilderIfNeeded<Second>,
TRequired
>
: AllOfNodesToBuilder<
[
IntersectionSchemaBuilder<
AsBuilderIfNeeded<First>,
AsBuilderIfNeeded<Second>
>,
...Rest
],
TRequired
>
: Acc extends readonly [infer Only]
? AsBuilderIfNeeded<Only, TRequired>
: SchemaBuilder<unknown, TRequired>;

/**
* Recursively maps a statically-known JSON Schema node (passed with
* `as const`) to the exact `@cleverbrush/schema` builder type, including:
Expand Down Expand Up @@ -328,11 +385,11 @@ export type JsonSchemaNodeToBuilder<S, TRequired extends boolean = true> =
readonly anyOf: infer Opts extends readonly unknown[];
}
? UnionSchemaBuilder<SchemaNodesTupleToBuilders<Opts>, TRequired>
: // allOf (not supported; falls back to any() at runtime)
: // allOf — left-fold into IntersectionSchemaBuilder chain
S extends {
readonly allOf: infer _Opts extends readonly unknown[];
readonly allOf: infer Opts extends readonly unknown[];
}
? SchemaBuilder<unknown, TRequired>
? AllOfNodesToBuilder<Opts, TRequired>
: // string
S extends { readonly type: 'string' }
? ExtendedStringBuilder<string, TRequired>
Expand Down
Loading
Loading