From 733b6f4b396521488d2fd51474710815a6f02c9b Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Sat, 2 May 2026 11:48:33 +0000 Subject: [PATCH 1/5] feat: add intersect() schema builder --- .changeset/tiny-dragons-shout.md | 11 + libs/schema-json/src/fromJsonSchema.test.ts | 13 +- libs/schema-json/src/fromJsonSchema.ts | 19 +- libs/schema-json/src/toJsonSchema.test.ts | 55 ++ libs/schema-json/src/toJsonSchema.ts | 16 + libs/schema/README.md | 11 + .../IntersectionSchemaBuilder.test.ts | 498 +++++++++++++++ .../src/builders/IntersectionSchemaBuilder.ts | 589 ++++++++++++++++++ libs/schema/src/core.ts | 5 + opencode.json | 23 + .../app/docs/sections/api-reference.tsx | 13 + .../schema/app/docs/sections/schema-types.tsx | 18 + .../migrating-from-zod/MigratingContent.tsx | 5 +- 13 files changed, 1262 insertions(+), 14 deletions(-) create mode 100644 .changeset/tiny-dragons-shout.md create mode 100644 libs/schema/src/builders/IntersectionSchemaBuilder.test.ts create mode 100644 libs/schema/src/builders/IntersectionSchemaBuilder.ts create mode 100644 opencode.json diff --git a/.changeset/tiny-dragons-shout.md b/.changeset/tiny-dragons-shout.md new file mode 100644 index 00000000..c09e7e3c --- /dev/null +++ b/.changeset/tiny-dragons-shout.md @@ -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. diff --git a/libs/schema-json/src/fromJsonSchema.test.ts b/libs/schema-json/src/fromJsonSchema.test.ts index f51f671f..b24b210f 100644 --- a/libs/schema-json/src/fromJsonSchema.test.ts +++ b/libs/schema-json/src/fromJsonSchema.test.ts @@ -319,15 +319,16 @@ 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>().toMatchTypeOf(); 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); }); // --------------------------------------------------------------------------- diff --git a/libs/schema-json/src/fromJsonSchema.ts b/libs/schema-json/src/fromJsonSchema.ts index b67836ae..4ccbc3e8 100644 --- a/libs/schema-json/src/fromJsonSchema.ts +++ b/libs/schema-json/src/fromJsonSchema.ts @@ -3,6 +3,7 @@ import { any, array, boolean, + intersection, nul, number, object, @@ -35,8 +36,8 @@ function buildNode(s: unknown): SchemaBuilder { 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']) { @@ -210,6 +211,15 @@ function buildAnyOf(options: unknown[]): SchemaBuilder { return b; } +function buildAllOf(options: unknown[]): SchemaBuilder { + 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. * @@ -248,9 +258,8 @@ function buildAnyOf(options: unknown[]): SchemaBuilder { * | `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. diff --git a/libs/schema-json/src/toJsonSchema.test.ts b/libs/schema-json/src/toJsonSchema.test.ts index 8632a496..e0414d8e 100644 --- a/libs/schema-json/src/toJsonSchema.test.ts +++ b/libs/schema-json/src/toJsonSchema.test.ts @@ -3,6 +3,7 @@ import { array, boolean, date, + intersection, lazy, nul, number, @@ -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 // --------------------------------------------------------------------------- diff --git a/libs/schema-json/src/toJsonSchema.ts b/libs/schema-json/src/toJsonSchema.ts index 606ff87d..a322e59b 100644 --- a/libs/schema-json/src/toJsonSchema.ts +++ b/libs/schema-json/src/toJsonSchema.ts @@ -211,6 +211,18 @@ function convertNodeInner( return out; } + case 'intersection': { + const left = info.left as SchemaBuilder; + const right = info.right as SchemaBuilder; + 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, @@ -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[]; diff --git a/libs/schema/README.md b/libs/schema/README.md index c66af55f..0054be8f 100644 --- a/libs/schema/README.md +++ b/libs/schema/README.md @@ -137,6 +137,7 @@ The following builder functions are available: | `tuple([...schemas])` | Fixed-length array with per-position types. Each index validated against its own schema — mirrors TypeScript tuple types. | `.rest(schema)`, `.optional()`, `.nullable()`, `.notNullable()`, `.default(value)` | | `record(keySchema, valSchema)` | Object with dynamic string keys. Every key must satisfy `keySchema` (a string schema) and every value must satisfy `valSchema` — mirrors TypeScript's `Record`. | `.optional()`, `.nullable()`, `.notNullable()`, `.default(value)`, `.addValidator(fn)` | | `union(schema)` | Union of schemas — e.g. `string \| number`. | `.or(schema)`, `.validate(data)`, `.optional()`, `.nullable()`, `.notNullable()`, `.default(value)` | +| `intersection(left, right)` | Intersection of two schemas — both must pass. Merges validated outputs. Use `.acceptUnknownProps()` on object schemas. | `.optional()`, `.nullable()`, `.notNullable()`, `.default(value)`, `.addValidator(fn)` | | `enumOf(...values)` | String enum — sugar for `string().oneOf(...)`. | `.optional()`, `.nullable()`, `.notNullable()`, `.default(value)` | | `lazy(getter)` | Recursive/self-referential schema. The getter is called once and its result is cached. Enables tree structures, linked lists, and other recursive types. | `.resolve()`, `.optional()`, `.addValidator(fn)`, `.default(value)` | | `generic(fn)` | Parameterized schema template. Call `.apply(...schemas)` with concrete schemas to obtain a fully typed concrete schema builder. TypeScript infers the result type from the template function's own generic signature. Optionally pass a `defaults` array as the first argument to enable direct validation without calling `.apply()`. | `.apply(...schemas)`, `.optional()`, `.nullable()`, `.default(value)` | @@ -199,6 +200,16 @@ const TeamSchema = object({ const IdOrEmail = union(string().minLength(1)).or( string().matches(/^[^@]+@[^@]+$/) ); + +// Intersection types — combine two schemas into one (both must pass) +import { intersection } from '@cleverbrush/schema'; + +const NameAndAge = intersection( + object({ name: string() }).acceptUnknownProps(), + object({ age: number() }).acceptUnknownProps() +); +const person = NameAndAge.parse({ name: 'Alice', age: 30 }); +// typeof person === { name: string } & { age: number } ``` ## Generic Schemas diff --git a/libs/schema/src/builders/IntersectionSchemaBuilder.test.ts b/libs/schema/src/builders/IntersectionSchemaBuilder.test.ts new file mode 100644 index 00000000..f98fefc7 --- /dev/null +++ b/libs/schema/src/builders/IntersectionSchemaBuilder.test.ts @@ -0,0 +1,498 @@ +import { expect, expectTypeOf, test } from 'vitest'; +import { intersection } from './IntersectionSchemaBuilder.js'; +import { number } from './NumberSchemaBuilder.js'; +import { object } from './ObjectSchemaBuilder.js'; +import type { InferType } from './SchemaBuilder.js'; +import { string } from './StringSchemaBuilder.js'; + +test('Intersection of two objects', async () => { + const schema = intersection( + object({ name: string() }).acceptUnknownProps(), + object({ age: number() }).acceptUnknownProps() + ); + + type T = InferType; + expectTypeOf().toEqualTypeOf<{ name: string } & { age: number }>(); + + const { valid, object: result } = await schema.validate({ + name: 'Alice', + age: 30 + }); + expect(valid).toEqual(true); + expect(result).toEqual({ name: 'Alice', age: 30 }); +}); + +test('Intersection fails when left schema fails', async () => { + const schema = intersection( + object({ name: string().minLength(1) }).acceptUnknownProps(), + object({ age: number() }).acceptUnknownProps() + ); + + const { valid, errors } = await schema.validate({ + name: '', + age: 30 + } as any); + expect(valid).toEqual(false); + expect(errors?.length).toBeGreaterThan(0); +}); + +test('Intersection fails when right schema fails', async () => { + const schema = intersection( + object({ name: string() }).acceptUnknownProps(), + object({ age: number().min(0) }).acceptUnknownProps() + ); + + const { valid, errors } = await schema.validate({ + name: 'Alice', + age: -1 + } as any); + expect(valid).toEqual(false); + expect(errors?.length).toBeGreaterThan(0); +}); + +test('Intersection of primitives', async () => { + const schema = intersection(string().minLength(3), string().maxLength(10)); + + type T = InferType; + expectTypeOf().toEqualTypeOf(); + + { + const { valid, object: result } = await schema.validate('hello'); + expect(valid).toEqual(true); + expect(result).toEqual('hello'); + } + + { + const { valid } = await schema.validate('ab'); + expect(valid).toEqual(false); + } + + { + const { valid } = await schema.validate('a'.repeat(11)); + expect(valid).toEqual(false); + } +}); + +test('Intersection with optional', async () => { + const schema = intersection( + object({ name: string() }).acceptUnknownProps(), + object({ age: number().optional() }).acceptUnknownProps() + ).optional(); + + type T = InferType; + expectTypeOf().toEqualTypeOf< + ({ name: string } & { age?: number }) | undefined + >(); + + { + const { valid, object: result } = await schema.validate(undefined); + expect(valid).toEqual(true); + expect(result).toBeUndefined(); + } + + { + const { valid, object: result } = await schema.validate({ + name: 'Alice' + }); + expect(valid).toEqual(true); + expect(result).toEqual({ name: 'Alice' }); + } +}); + +test('Intersection with nullable', async () => { + const schema = intersection( + object({ name: string() }).acceptUnknownProps(), + object({ age: number() }).acceptUnknownProps() + ).nullable(); + + type T = InferType; + expectTypeOf().toEqualTypeOf< + ({ name: string } & { age: number }) | null + >(); + + { + const { valid, object: result } = await schema.validate(null); + expect(valid).toEqual(true); + expect(result).toBeNull(); + } +}); + +test('Intersection with default value', async () => { + const schema = intersection( + object({ name: string() }).acceptUnknownProps(), + object({ age: number() }).acceptUnknownProps() + ).default({ name: 'Default', age: 0 } as any); + + { + const { valid, object: result } = await schema.validate(undefined); + expect(valid).toEqual(true); + expect(result).toEqual({ name: 'Default', age: 0 }); + } +}); + +test('Intersection returns merged object', async () => { + const schema = intersection( + object({ name: string() }).acceptUnknownProps(), + object({ age: number() }).acceptUnknownProps() + ); + + const { valid, object: result } = await schema.validate({ + name: 'Bob', + age: 25 + }); + expect(valid).toEqual(true); + expect(result).toEqual({ name: 'Bob', age: 25 }); +}); + +test('Intersection with nullable', async () => { + const schema = intersection( + object({ name: string() }), + object({ age: number() }) + ).nullable(); + + type T = InferType; + expectTypeOf().toEqualTypeOf< + ({ name: string } & { age: number }) | null + >(); + + { + const { valid, object: result } = await schema.validate(null); + expect(valid).toEqual(true); + expect(result).toBeNull(); + } +}); + +test('Intersection with default value', async () => { + const schema = intersection( + object({ name: string() }).acceptUnknownProps(), + object({ age: number() }).acceptUnknownProps() + ).default({ name: 'Default', age: 0 } as any); + + { + const { valid, object: result } = await schema.validate(undefined); + expect(valid).toEqual(true); + expect(result).toEqual({ name: 'Default', age: 0 }); + } +}); + +test('Intersection with catch value', async () => { + const schema = intersection( + object({ name: string() }), + object({ age: number() }) + ).catch({ name: 'Fallback', age: 99 } as any); + + { + const { valid, object: result } = await schema.validate(null as any); + expect(valid).toEqual(true); + expect(result).toEqual({ name: 'Fallback', age: 99 }); + } +}); + +test('Intersection returns merged object', async () => { + const schema = intersection( + object({ name: string() }).acceptUnknownProps(), + object({ age: number() }).acceptUnknownProps() + ); + + const { valid, object: result } = await schema.validate({ + name: 'Bob', + age: 25 + }); + expect(valid).toEqual(true); + expect(result).toEqual({ name: 'Bob', age: 25 }); +}); + +test('Intersection handles overlapping properties', async () => { + const schema = intersection( + object({ value: string().minLength(1) }), + object({ value: string().maxLength(10) }) + ); + + const { valid, object: result } = await schema.validate({ + value: 'test' + }); + expect(valid).toEqual(true); + expect(result).toEqual({ value: 'test' }); +}); + +test('Intersection with readonly', async () => { + const schema = intersection( + object({ name: string() }), + object({ age: number() }) + ).readonly(); + + type T = InferType; + expectTypeOf().toEqualTypeOf< + Readonly<{ name: string } & { age: number }> + >(); +}); + +test('Intersection with brand', async () => { + const schema = intersection( + object({ name: string() }), + object({ age: number() }) + ).brand<'Person'>(); + + type T = InferType; + expectTypeOf().toEqualTypeOf< + ({ name: string } & { age: number }) & { readonly __brand: 'Person' } + >(); +}); + +test('Intersection with describe', async () => { + const schema = intersection( + object({ name: string() }), + object({ age: number() }) + ).describe('A person'); + + expect(schema.introspect().description).toEqual('A person'); +}); + +test('Intersection with schemaName', async () => { + const schema = intersection( + object({ name: string() }), + object({ age: number() }) + ).schemaName('Person'); + + expect(schema.introspect().schemaName).toEqual('Person'); +}); + +test('Intersection with example', async () => { + const example = { name: 'Alice', age: 30 }; + const schema = intersection( + object({ name: string() }), + object({ age: number() }) + ).example(example); + + expect(schema.introspect().example).toEqual(example); +}); + +test('Intersection with addPreprocessor', async () => { + const schema = intersection( + object({ name: string() }).acceptUnknownProps(), + object({ age: number() }).acceptUnknownProps() + ).addPreprocessor((obj: any) => ({ + ...obj, + name: (obj.name || '').toString().trim() + })); + + const { valid, object: result } = await schema.validate({ + name: ' Alice ', + age: 30 + } as any); + expect(valid).toEqual(true); + expect(result).toEqual({ name: 'Alice', age: 30 }); +}); + +test('Intersection with addValidator', async () => { + const schema = intersection( + object({ name: string() }).acceptUnknownProps(), + object({ age: number().min(0) }).acceptUnknownProps() + ).addValidator((obj: any) => { + if (obj.name === 'Admin' && obj.age < 18) { + return { + valid: false, + errors: [{ message: 'Admin must be at least 18' }] + }; + } + return { valid: true, errors: [] }; + }); + + { + const { valid } = await schema.validate({ + name: 'Admin', + age: 15 + } as any); + expect(valid).toEqual(false); + } + + { + const { valid } = await schema.validate({ + name: 'Admin', + age: 25 + } as any); + expect(valid).toEqual(true); + } +}); + +test('Intersection is immutable', () => { + const schema1 = intersection( + object({ name: string() }), + object({ age: number() }) + ); + const schema2 = intersection( + object({ name: string() }), + object({ age: number() }) + ).optional(); + + expect(schema1).not.toBe(schema2); + expect((schema1.introspect() as any).isRequired).toEqual(true); + expect((schema2.introspect() as any).isRequired).toEqual(false); +}); + +test('Intersection introspection', () => { + const leftSchema = object({ name: string() }); + const rightSchema = object({ age: number() }); + const schema = intersection(leftSchema, rightSchema); + + const info = schema.introspect(); + expect(info.type).toEqual('intersection'); + expect(info.left).toBe(leftSchema); + expect(info.right).toBe(rightSchema); +}); + +test('Intersection with hasType', async () => { + const schema = intersection( + object({ name: string() }).acceptUnknownProps(), + object({ age: number() }).acceptUnknownProps() + ).hasType<{ name: string; age: number }>(); + + type T = InferType; + expectTypeOf().toEqualTypeOf<{ name: string; age: number }>(); + + const { valid, object: result } = await schema.validate({ + name: 'Alice', + age: 30 + }); + expect(valid).toEqual(true); + expect(result).toEqual({ name: 'Alice', age: 30 }); +}); + +test('Intersection validateAsync', async () => { + const schema = intersection( + object({ name: string().minLength(1) }).acceptUnknownProps(), + object({ age: number().min(0) }).acceptUnknownProps() + ); + + { + const { valid, object: result } = await schema.validateAsync({ + name: 'Alice', + age: 30 + }); + expect(valid).toEqual(true); + expect(result).toEqual({ name: 'Alice', age: 30 }); + } + + { + const { valid } = await schema.validateAsync({ + name: '', + age: 30 + } as any); + expect(valid).toEqual(false); + } + + { + const { valid } = await schema.validateAsync({ + name: 'Alice', + age: -1 + } as any); + expect(valid).toEqual(false); + } +}); + +test('Intersection parse throws on invalid', () => { + const schema = intersection( + object({ name: string() }).acceptUnknownProps(), + object({ age: number() }).acceptUnknownProps() + ); + + expect(() => + schema.parse({ name: 'Alice', age: 'not-a-number' } as any) + ).toThrow(); +}); + +test('Intersection parse returns value on valid', () => { + const schema = intersection( + object({ name: string() }).acceptUnknownProps(), + object({ age: number() }).acceptUnknownProps() + ); + + const result = schema.parse({ name: 'Alice', age: 30 }); + expect(result).toEqual({ name: 'Alice', age: 30 }); +}); + +test('Intersection safeParse', () => { + const schema = intersection( + object({ name: string() }).acceptUnknownProps(), + object({ age: number() }).acceptUnknownProps() + ); + + { + const result = schema.safeParse({ name: 'Alice', age: 30 }); + expect(result.valid).toEqual(true); + expect(result.object).toEqual({ name: 'Alice', age: 30 }); + } + + { + const result = schema.safeParse({ name: 'Alice' } as any); + expect(result.valid).toEqual(false); + } +}); + +test('Intersection safeParseAsync', async () => { + const schema = intersection( + object({ name: string() }).acceptUnknownProps(), + object({ age: number() }).acceptUnknownProps() + ); + + const result = await schema.safeParseAsync({ name: 'Alice', age: 30 }); + expect(result.valid).toEqual(true); + expect(result.object).toEqual({ name: 'Alice', age: 30 }); +}); + +test('Intersection of intersection', async () => { + const base = intersection( + object({ name: string() }).acceptUnknownProps(), + object({ age: number() }).acceptUnknownProps() + ); + const extended = intersection( + base, + object({ email: string() }).acceptUnknownProps() + ); + + type T = InferType; + expectTypeOf().toEqualTypeOf< + ({ name: string } & { age: number }) & { email: string } + >(); + + const { valid, object: result } = await extended.validate({ + name: 'Alice', + age: 30, + email: 'alice@example.com' + }); + expect(valid).toEqual(true); + expect(result).toEqual({ + name: 'Alice', + age: 30, + email: 'alice@example.com' + }); +}); + +test('Intersection required method', async () => { + const schema = intersection( + object({ name: string() }), + object({ age: number() }) + ) + .optional() + .required(); + + type T = InferType; + expectTypeOf().toEqualTypeOf<{ name: string } & { age: number }>(); + + { + const { valid } = await schema.validate(undefined as any); + expect(valid).toEqual(false); + } +}); + +test('Intersection clearDefault', async () => { + const withDefault = intersection( + object({ name: string() }), + object({ age: number() }) + ).default({ name: 'X', age: 0 } as any); + + expect((withDefault.introspect() as any).defaultValue).toBeDefined(); + + const withoutDefault = withDefault.clearDefault(); + expect((withoutDefault.introspect() as any).defaultValue).toBeUndefined(); +}); diff --git a/libs/schema/src/builders/IntersectionSchemaBuilder.ts b/libs/schema/src/builders/IntersectionSchemaBuilder.ts new file mode 100644 index 00000000..6d746c6a --- /dev/null +++ b/libs/schema/src/builders/IntersectionSchemaBuilder.ts @@ -0,0 +1,589 @@ +import { + type BRAND, + type InferType, + SchemaBuilder, + type ValidationContext, + type ValidationResult +} from './SchemaBuilder.js'; + +type IntersectionSchemaBuilderCreateProps< + TLeft extends SchemaBuilder, + TRight extends SchemaBuilder, + R extends boolean = true +> = Partial< + ReturnType['introspect']> +>; + +type SchemaIntersection< + TLeft extends SchemaBuilder, + TRight extends SchemaBuilder +> = InferType & InferType; + +export type IntersectionSchemaValidationResult = ValidationResult; + +export class IntersectionSchemaBuilder< + TLeft extends SchemaBuilder, + TRight extends SchemaBuilder, + TRequired extends boolean = true, + TNullable extends boolean = false, + TExplicitType = undefined, + THasDefault extends boolean = false, + TExtensions = {} +> extends SchemaBuilder< + TExplicitType extends undefined + ? SchemaIntersection + : TExplicitType, + TRequired, + TNullable, + THasDefault, + TExtensions +> { + #left!: TLeft; + #right!: TRight; + + /** + * @hidden + */ + public static create( + props: IntersectionSchemaBuilderCreateProps + ) { + return new IntersectionSchemaBuilder({ + type: 'intersection', + ...props + }); + } + + protected constructor( + props: IntersectionSchemaBuilderCreateProps + ) { + super(props as any); + + if (props.left instanceof SchemaBuilder) { + this.#left = props.left; + } + if (props.right instanceof SchemaBuilder) { + this.#right = props.right; + } + } + + public introspect() { + return { + ...super.introspect(), + left: this.#left, + right: this.#right + }; + } + + /** + * @override + */ + protected override get isNullRequiredViolation(): boolean { + return false; + } + + /** + * @inheritdoc + */ + public hasType( + _notUsed?: T + ): IntersectionSchemaBuilder< + TLeft, + TRight, + true, + TNullable, + T, + THasDefault, + TExtensions + > & + TExtensions { + return this.createFromProps({ + ...this.introspect() + } as any) as any; + } + + /** + * @inheritdoc + */ + public clearHasType(): IntersectionSchemaBuilder< + TLeft, + TRight, + TRequired, + TNullable, + undefined, + THasDefault, + TExtensions + > & + TExtensions { + return this.createFromProps({ + ...this.introspect() + } as any) as any; + } + + /** + * @inheritdoc + */ + public validate( + object: TExplicitType extends undefined + ? SchemaIntersection + : TExplicitType, + context?: ValidationContext + ): IntersectionSchemaValidationResult< + TExplicitType extends undefined + ? SchemaIntersection + : TExplicitType + > { + return super.validate(object, context) as any; + } + + /** + * @inheritdoc + */ + public async validateAsync( + object: TExplicitType extends undefined + ? SchemaIntersection + : TExplicitType, + context?: ValidationContext + ): Promise< + IntersectionSchemaValidationResult< + TExplicitType extends undefined + ? SchemaIntersection + : TExplicitType + > + > { + return super.validateAsync(object, context) as any; + } + + #mergeObjects(leftObj: any, rightObj: any): any { + if ( + typeof leftObj === 'object' && + leftObj !== null && + typeof rightObj === 'object' && + rightObj !== null && + !Array.isArray(leftObj) && + !Array.isArray(rightObj) + ) { + return { ...leftObj, ...rightObj }; + } + return rightObj; + } + + #createValidationSetup( + superResult: ReturnType< + IntersectionSchemaBuilder['preValidateSync'] + > + ) { + const { + valid, + transaction: preValidationTransaction, + context: prevalidationContext, + errors + } = superResult; + + if (!valid) { + return { + needsValidation: false as const, + result: { valid, errors } as any + }; + } + + const { + object: { validatedObject: objToValidate } + } = preValidationTransaction!; + + if ( + (typeof objToValidate === 'undefined' && !this.isRequired) || + (objToValidate === null && (!this.isRequired || this.isNullable)) + ) { + return { + needsValidation: false as const, + result: { + valid: true, + object: objToValidate + } as any + }; + } + + return { + needsValidation: true as const, + objToValidate, + prevalidationContext + }; + } + + /** + * Performs synchronous validation. + * Validates left schema first, then right schema. + * Both must pass for the intersection to be valid. + */ + protected _validate( + object: TExplicitType extends undefined + ? SchemaIntersection + : TExplicitType, + context?: ValidationContext + ): IntersectionSchemaValidationResult< + TExplicitType extends undefined + ? SchemaIntersection + : TExplicitType + > { + if ( + this.canSkipPreValidation && + !context?.doNotStopOnFirstError && + !context?.rootPropertyDescriptor + ) { + if (typeof object === 'undefined') { + if (this.hasDefault) { + object = this.resolveDefaultValue(); + } else if (!this.isRequired) { + return { valid: true, object } as any; + } else { + return { + valid: false, + errors: [ + { + message: this.getValidationErrorMessageSync( + this.requiredErrorMessage, + object as any + ) + } + ] + } as any; + } + } else if ( + object === null && + (!this.isRequired || this.isNullable) + ) { + return { valid: true, object } as any; + } + + const leftResult = this.#left.validate(object as any); + if (!leftResult.valid) { + return { + valid: false, + errors: leftResult.errors + } as any; + } + + const rightResult = this.#right.validate(object as any); + if (!rightResult.valid) { + return { + valid: false, + errors: rightResult.errors + } as any; + } + + const mergedObject = this.#mergeObjects( + leftResult.object, + rightResult.object + ); + return { valid: true, object: mergedObject } as any; + } + + return this.#validateFull(object, context); + } + + #validateFull( + object: any, + context?: ValidationContext + ): IntersectionSchemaValidationResult { + const setup = this.#createValidationSetup( + this.preValidateSync(object, context) + ); + + if (!setup.needsValidation) return setup.result; + + const { objToValidate, prevalidationContext } = setup; + + const leftResult = this.#left.validate(objToValidate, { + ...prevalidationContext, + currentPropertyDescriptor: undefined, + rootPropertyDescriptor: undefined + } as any); + + if (!leftResult.valid) { + return { + valid: false, + errors: leftResult.errors + }; + } + + const rightResult = this.#right.validate(objToValidate, { + ...prevalidationContext, + currentPropertyDescriptor: undefined, + rootPropertyDescriptor: undefined + } as any); + + if (!rightResult.valid) { + return { + valid: false, + errors: rightResult.errors + }; + } + + const mergedObject = this.#mergeObjects( + leftResult.object, + rightResult.object + ); + + return { + valid: true, + object: mergedObject + }; + } + + /** + * Performs async validation. + */ + protected async _validateAsync( + object: TExplicitType extends undefined + ? SchemaIntersection + : TExplicitType, + context?: ValidationContext + ): Promise< + IntersectionSchemaValidationResult< + TExplicitType extends undefined + ? SchemaIntersection + : TExplicitType + > + > { + const setup = this.#createValidationSetup( + await super.preValidateAsync(object, context) + ); + + if (!setup.needsValidation) return setup.result; + + const { objToValidate, prevalidationContext } = setup; + + const leftResult = await this.#left.validateAsync(objToValidate, { + ...prevalidationContext, + currentPropertyDescriptor: undefined, + rootPropertyDescriptor: undefined + } as any); + + if (!leftResult.valid) { + return { + valid: false, + errors: leftResult.errors + }; + } + + const rightResult = await this.#right.validateAsync(objToValidate, { + ...prevalidationContext, + currentPropertyDescriptor: undefined, + rootPropertyDescriptor: undefined + } as any); + + if (!rightResult.valid) { + return { + valid: false, + errors: rightResult.errors + }; + } + + const mergedObject = this.#mergeObjects( + leftResult.object, + rightResult.object + ); + + return { + valid: true, + object: mergedObject + }; + } + + protected createFromProps< + TL extends SchemaBuilder, + TR extends SchemaBuilder, + TReq extends boolean + >(props: IntersectionSchemaBuilderCreateProps): this { + return IntersectionSchemaBuilder.create(props as any) as any; + } + + /** + * @hidden + */ + public required( + errorMessage?: any + ): IntersectionSchemaBuilder< + TLeft, + TRight, + true, + TNullable, + TExplicitType, + THasDefault, + TExtensions + > & + TExtensions { + return super.required(errorMessage); + } + + /** + * @hidden + */ + public optional(): IntersectionSchemaBuilder< + TLeft, + TRight, + false, + TNullable, + TExplicitType, + THasDefault, + TExtensions + > & + TExtensions { + return super.optional(); + } + + /** + * @hidden + */ + public default( + value: + | (TExplicitType extends undefined + ? SchemaIntersection + : TExplicitType) + | (() => TExplicitType extends undefined + ? SchemaIntersection + : TExplicitType) + ): IntersectionSchemaBuilder< + TLeft, + TRight, + true, + TNullable, + TExplicitType, + true, + TExtensions + > & + TExtensions { + return super.default(value as any) as any; + } + + /** + * @hidden + */ + public clearDefault(): IntersectionSchemaBuilder< + TLeft, + TRight, + TRequired, + TNullable, + TExplicitType, + false, + TExtensions + > & + TExtensions { + return super.clearDefault() as any; + } + + /** + * @hidden + */ + public brand( + _name?: TBrand + ): IntersectionSchemaBuilder< + TLeft, + TRight, + TRequired, + TNullable, + (TExplicitType extends undefined + ? SchemaIntersection + : TExplicitType) & { readonly [K in BRAND]: TBrand }, + THasDefault, + TExtensions + > & + TExtensions { + return super.brand(_name); + } + + /** + * @hidden + */ + public readonly(): IntersectionSchemaBuilder< + TLeft, + TRight, + TRequired, + TNullable, + Readonly< + TExplicitType extends undefined + ? SchemaIntersection + : TExplicitType + >, + THasDefault, + TExtensions + > & + TExtensions { + return super.readonly(); + } + + /** + * @hidden + */ + public nullable(): IntersectionSchemaBuilder< + TLeft, + TRight, + TRequired, + true, + TExplicitType, + THasDefault, + TExtensions + > & + TExtensions { + return super.nullable() as any; + } + + /** + * @hidden + */ + public notNullable(): IntersectionSchemaBuilder< + TLeft, + TRight, + TRequired, + false, + TExplicitType, + THasDefault, + TExtensions + > & + TExtensions { + return super.notNullable() as any; + } + + /** + * Gets the left side of this intersection. + */ + public get leftSchema(): TLeft { + return this.#left; + } + + /** + * Gets the right side of this intersection. + */ + public get rightSchema(): TRight { + return this.#right; + } +} + +/** + * Creates an intersection schema. + * The resulting schema validates that the input satisfies both `left` and `right` schemas. + * + * @example + * ```ts + * const schema = intersection( + * object({ name: string() }), + * object({ age: number() }) + * ); + * // InferType === { name: string } & { age: number } + * ``` + * + * @param left - first schema + * @param right - second schema + */ +export const intersection = < + TLeft extends SchemaBuilder, + TRight extends SchemaBuilder +>( + left: TLeft, + right: TRight +) => + IntersectionSchemaBuilder.create({ + isRequired: true, + left, + right + }) as IntersectionSchemaBuilder; diff --git a/libs/schema/src/core.ts b/libs/schema/src/core.ts index 9a08d7f7..023f3b3b 100644 --- a/libs/schema/src/core.ts +++ b/libs/schema/src/core.ts @@ -25,6 +25,11 @@ export { GenericSchemaBuilder, generic } from './builders/GenericSchemaBuilder.js'; +export type { IntersectionSchemaValidationResult } from './builders/IntersectionSchemaBuilder.js'; +export { + IntersectionSchemaBuilder, + intersection +} from './builders/IntersectionSchemaBuilder.js'; export { LazySchemaBuilder, lazy } from './builders/LazySchemaBuilder.js'; export { NullSchemaBuilder, nul } from './builders/NullSchemaBuilder.js'; export { NumberSchemaBuilder, number } from './builders/NumberSchemaBuilder.js'; diff --git a/opencode.json b/opencode.json new file mode 100644 index 00000000..cc37eda1 --- /dev/null +++ b/opencode.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "edit": "allow", + "bash": "ask", + "webfetch": "allow", + "doom_loop": "allow", + "external_directory": "ask", + "glob": "allow", + "grep": "allow", + "list": "allow", + "read": "allow", + "lsp": "allow", + "question": "allow", + "skill": "allow", + "task": "allow", + "write": "allow", + "todowrite": "allow", + "websearch": "allow" + }, + "instructions": ["MIGRATION_PLAN.md"], + "plugin": [ "openrtk" ] +} \ No newline at end of file diff --git a/websites/schema/app/docs/sections/api-reference.tsx b/websites/schema/app/docs/sections/api-reference.tsx index 6c008178..23a1c46b 100644 --- a/websites/schema/app/docs/sections/api-reference.tsx +++ b/websites/schema/app/docs/sections/api-reference.tsx @@ -142,6 +142,19 @@ export default function ApiReferenceSection() { .catch(value) + + + intersection(left, right) + + Intersection of two schemas + + .validate(data),{' '} + .validateAsync(data),{' '} + .default(value),{' '} + .nullable(),{' '} + .catch(value) + + lazy(getter) diff --git a/websites/schema/app/docs/sections/schema-types.tsx b/websites/schema/app/docs/sections/schema-types.tsx index be0de979..3a8ae95a 100644 --- a/websites/schema/app/docs/sections/schema-types.tsx +++ b/websites/schema/app/docs/sections/schema-types.tsx @@ -234,6 +234,24 @@ export default function SchemaTypesSection() { .readonly() + + + intersection(left, right) + + + Value must match both schemas (logical AND). + Merges validated outputs. + + + .validate(data),{' '} + .validateAsync(data),{' '} + .optional(),{' '} + .nullable(),{' '} + .default(value),{' '} + .catch(value),{' '} + .readonly() + + lazy(getter) diff --git a/websites/schema/app/migrating-from-zod/MigratingContent.tsx b/websites/schema/app/migrating-from-zod/MigratingContent.tsx index 9d0d6639..41b7374a 100644 --- a/websites/schema/app/migrating-from-zod/MigratingContent.tsx +++ b/websites/schema/app/migrating-from-zod/MigratingContent.tsx @@ -1130,11 +1130,10 @@ const PostSlug = s().slug().minLength(3).maxLength(60);` z.intersection(a, b) - Object-level: .intersect(){' '} - covers it + intersection(a, b) - Use object().intersect(other) + intersection(a, b) From 23dc7788c4b024b2abd084d045af6b685dc37295 Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Tue, 5 May 2026 08:09:23 +0000 Subject: [PATCH 2/5] chore: update documentation --- libs/schema-json/README.md | 3 +-- libs/schema-json/src/types.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/libs/schema-json/README.md b/libs/schema-json/README.md index 9c5e5888..6e81b819 100644 --- a/libs/schema-json/README.md +++ b/libs/schema-json/README.md @@ -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()` | @@ -288,7 +288,6 @@ type B = JsonSchemaNodeToBuilder; | `$ref` / `$defs` | Not supported in `fromJsonSchema` | | `if` / `then` / `else` | Not supported | | `not` | Not supported | -| `allOf` in `fromJsonSchema` | Falls back to `SchemaBuilder` (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()` | diff --git a/libs/schema-json/src/types.ts b/libs/schema-json/src/types.ts index ad79f78e..5404511c 100644 --- a/libs/schema-json/src/types.ts +++ b/libs/schema-json/src/types.ts @@ -328,7 +328,7 @@ export type JsonSchemaNodeToBuilder = readonly anyOf: infer Opts extends readonly unknown[]; } ? UnionSchemaBuilder, TRequired> - : // allOf (not supported; falls back to any() at runtime) + : // allOf — runtime maps to intersection(); type-level inference is a future enhancement S extends { readonly allOf: infer _Opts extends readonly unknown[]; } From e54d6277c65dba3e864628b7ab2c18d466945b36 Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Tue, 5 May 2026 13:04:50 +0000 Subject: [PATCH 3/5] fix: typing for intersection --- libs/schema-json/src/fromJsonSchema.test.ts | 84 ++++++++++++++++++++- libs/schema-json/src/types.ts | 67 ++++++++++++++-- 2 files changed, 145 insertions(+), 6 deletions(-) diff --git a/libs/schema-json/src/fromJsonSchema.test.ts b/libs/schema-json/src/fromJsonSchema.test.ts index b24b210f..5c67a764 100644 --- a/libs/schema-json/src/fromJsonSchema.test.ts +++ b/libs/schema-json/src/fromJsonSchema.test.ts @@ -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'; @@ -326,11 +326,93 @@ test('fromJsonSchema - 28b: allOf maps to intersection', () => { { type: 'string', maxLength: 10 } ] } as const); + + // Type-level: InferType should be string (intersection of string & string) + expectTypeOf>().toMatchTypeOf(); + + // Type-level: should NOT be unknown + expectTypeOf>().not.toMatchTypeOf(); + + // Type-level: schema should be an IntersectionSchemaBuilder + expectTypeOf().toMatchTypeOf< + IntersectionSchemaBuilder + >(); + expect(valid(schema, 'hello')).toBe(true); 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>().toMatchTypeOf<{ + name: string; + age: number; + }>(); + expectTypeOf>().not.toMatchTypeOf(); +}); + +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>().toMatchTypeOf<{ + name: string; + age: number; + email: string; + }>(); + expectTypeOf>().not.toMatchTypeOf(); +}); + +test('fromJsonSchema - 28e: allOf single element returns direct builder type', () => { + const schema = fromJsonSchema({ + allOf: [{ type: 'string', minLength: 1 }] + } as const); + + expectTypeOf>().toMatchTypeOf(); + expectTypeOf>().not.toMatchTypeOf(); +}); + +test('fromJsonSchema - 28f: allOf empty returns unknown builder', () => { + const schema = fromJsonSchema({ + allOf: [] + } as const); + + expectTypeOf>().toMatchTypeOf(); +}); + // --------------------------------------------------------------------------- // empty schema // --------------------------------------------------------------------------- diff --git a/libs/schema-json/src/types.ts b/libs/schema-json/src/types.ts index 5404511c..089b9748 100644 --- a/libs/schema-json/src/types.ts +++ b/libs/schema-json/src/types.ts @@ -8,6 +8,7 @@ import type { ExtendedArray, ExtendedNumber, ExtendedString, + IntersectionSchemaBuilder, ObjectSchemaBuilder, SchemaBuilder, UnionSchemaBuilder @@ -120,9 +121,18 @@ export type InferFromJsonSchema = S extends { readonly const: infer V } : S extends { readonly anyOf: readonly (infer U)[] } ? InferFromJsonSchema : S extends { - readonly allOf: readonly JsonSchemaNode[]; + readonly allOf: readonly [ + infer First extends JsonSchemaNode, + ...infer Rest extends + readonly JsonSchemaNode[] + ]; } - ? unknown + ? Rest extends readonly [] + ? InferFromJsonSchema + : InferFromJsonSchema & + InferFromJsonSchema<{ + readonly allOf: Rest; + }> : unknown; /** Options accepted by {@link toJsonSchema}. */ @@ -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 extends SchemaBuilder + ? X + : JsonSchemaNodeToBuilder; + +/** + * 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, + AsBuilderIfNeeded, + TRequired + > + : AllOfNodesToBuilder< + [ + IntersectionSchemaBuilder< + AsBuilderIfNeeded, + AsBuilderIfNeeded + >, + ...Rest + ], + TRequired + > + : Acc extends readonly [infer Only] + ? AsBuilderIfNeeded + : SchemaBuilder; + /** * Recursively maps a statically-known JSON Schema node (passed with * `as const`) to the exact `@cleverbrush/schema` builder type, including: @@ -328,11 +385,11 @@ export type JsonSchemaNodeToBuilder = readonly anyOf: infer Opts extends readonly unknown[]; } ? UnionSchemaBuilder, TRequired> - : // allOf — runtime maps to intersection(); type-level inference is a future enhancement + : // allOf — left-fold into IntersectionSchemaBuilder chain S extends { - readonly allOf: infer _Opts extends readonly unknown[]; + readonly allOf: infer Opts extends readonly unknown[]; } - ? SchemaBuilder + ? AllOfNodesToBuilder : // string S extends { readonly type: 'string' } ? ExtendedStringBuilder From 4fb7938404507c7cca9971ee9bb58ea4e52371c1 Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Tue, 5 May 2026 13:16:47 +0000 Subject: [PATCH 4/5] fix(tests): update validation calls to use 'as any' for undefined and null cases --- .../src/builders/IntersectionSchemaBuilder.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/libs/schema/src/builders/IntersectionSchemaBuilder.test.ts b/libs/schema/src/builders/IntersectionSchemaBuilder.test.ts index f98fefc7..0db236a3 100644 --- a/libs/schema/src/builders/IntersectionSchemaBuilder.test.ts +++ b/libs/schema/src/builders/IntersectionSchemaBuilder.test.ts @@ -85,7 +85,7 @@ test('Intersection with optional', async () => { >(); { - const { valid, object: result } = await schema.validate(undefined); + const { valid, object: result } = schema.validate(undefined as any); expect(valid).toEqual(true); expect(result).toBeUndefined(); } @@ -111,7 +111,7 @@ test('Intersection with nullable', async () => { >(); { - const { valid, object: result } = await schema.validate(null); + const { valid, object: result } = schema.validate(null as any); expect(valid).toEqual(true); expect(result).toBeNull(); } @@ -124,7 +124,7 @@ test('Intersection with default value', async () => { ).default({ name: 'Default', age: 0 } as any); { - const { valid, object: result } = await schema.validate(undefined); + const { valid, object: result } = schema.validate(undefined as any); expect(valid).toEqual(true); expect(result).toEqual({ name: 'Default', age: 0 }); } @@ -156,7 +156,7 @@ test('Intersection with nullable', async () => { >(); { - const { valid, object: result } = await schema.validate(null); + const { valid, object: result } = schema.validate(null as any); expect(valid).toEqual(true); expect(result).toBeNull(); } @@ -169,7 +169,7 @@ test('Intersection with default value', async () => { ).default({ name: 'Default', age: 0 } as any); { - const { valid, object: result } = await schema.validate(undefined); + const { valid, object: result } = schema.validate(undefined as any); expect(valid).toEqual(true); expect(result).toEqual({ name: 'Default', age: 0 }); } From 69e030f514fc12b399b2c50acf2de2dcbd2c400d Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Tue, 5 May 2026 13:24:05 +0000 Subject: [PATCH 5/5] fix: typings --- libs/schema/src/builders/IntersectionSchemaBuilder.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/libs/schema/src/builders/IntersectionSchemaBuilder.test.ts b/libs/schema/src/builders/IntersectionSchemaBuilder.test.ts index 0db236a3..a256d103 100644 --- a/libs/schema/src/builders/IntersectionSchemaBuilder.test.ts +++ b/libs/schema/src/builders/IntersectionSchemaBuilder.test.ts @@ -2,7 +2,7 @@ import { expect, expectTypeOf, test } from 'vitest'; import { intersection } from './IntersectionSchemaBuilder.js'; import { number } from './NumberSchemaBuilder.js'; import { object } from './ObjectSchemaBuilder.js'; -import type { InferType } from './SchemaBuilder.js'; +import type { BRAND, InferType } from './SchemaBuilder.js'; import { string } from './StringSchemaBuilder.js'; test('Intersection of two objects', async () => { @@ -234,9 +234,8 @@ test('Intersection with brand', async () => { ).brand<'Person'>(); type T = InferType; - expectTypeOf().toEqualTypeOf< - ({ name: string } & { age: number }) & { readonly __brand: 'Person' } - >(); + expectTypeOf().toExtend<{ name: string } & { age: number }>(); + expectTypeOf().toExtend<{ readonly [K in BRAND]: 'Person' }>(); }); test('Intersection with describe', async () => {