Skip to content
Open
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
34 changes: 31 additions & 3 deletions apps/web/src/lib/zod/deep-strict.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,38 @@ describe('deepStrict', () => {
}
});

test('rejects unknown keys inside object intersections', () => {
const schema = deepStrict(
z.intersection(z.object({ a: z.object({ value: z.string() }) }), z.object({ b: z.number() }))
);

expect(schema.safeParse({ a: { value: 'x' }, b: 1 }).success).toBe(true);
expect(schema.safeParse({ a: { value: 'x' }, b: 1, typo: true }).success).toBe(false);
expect(schema.safeParse({ a: { value: 'x', typo: true }, b: 1 }).success).toBe(false);
});

test('supports chained object intersections', () => {
const schema = deepStrict(
z
.object({ a: z.string() })
.and(z.object({ b: z.number() }))
.and(z.object({ c: z.boolean() }))
);

expect(schema.safeParse({ a: 'x', b: 1, c: true }).success).toBe(true);
expect(schema.safeParse({ a: 'x', b: 1, c: true, typo: 1 }).success).toBe(false);
});

test('preserves validation for overlapping intersection keys', () => {
const schema = deepStrict(
z.intersection(z.object({ value: z.string() }), z.object({ value: z.string().min(2) }))
);

expect(schema.safeParse({ value: 'ok' }).success).toBe(true);
expect(schema.safeParse({ value: 'x' }).success).toBe(false);
});

test('throws on unsupported wrappers so new Zod types surface loudly', () => {
expect(() =>
deepStrict(z.intersection(z.object({ a: z.string() }), z.object({ b: z.number() })))
).toThrow(/deepStrict: unsupported Zod type 'intersection'/);
expect(() => deepStrict(z.tuple([z.string(), z.number()]))).toThrow(
/deepStrict: unsupported Zod type 'tuple'/
);
Expand Down
45 changes: 41 additions & 4 deletions apps/web/src/lib/zod/deep-strict.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,16 @@ import { z } from 'zod';
* document (e.g. an admin editing a custom-LLM definition).
*
* Handled wrappers: object, optional, nullable, array, record, union
* (covers `z.discriminatedUnion`, which shares `type: 'union'`). These are
* the wrappers used by `CustomLlmDefinitionSchema` today.
* (covers `z.discriminatedUnion`, which shares `type: 'union'`), and
* intersection. These include the wrappers used by `CustomLlmDefinitionSchema`
* today.
*
* Recognised leaves (string, number, boolean, date, enum, literal,
* template_literal, bigint, symbol, null, undefined, void, never, any,
* unknown, nan, file) are returned unchanged.
*
* Any other Zod type (intersection, tuple, map, set, nonoptional, default,
* prefault, readonly, catch, pipe, lazy, transform, promise, function,
* Any other Zod type (tuple, map, set, nonoptional, default, prefault,
* readonly, catch, pipe, lazy, transform, promise, function,
* custom, success) throws, so a future schema change that introduces a new
* wrapper surfaces here instead of silently skipping deep-strict and
* allowing unknown keys through.
Expand All @@ -43,6 +44,30 @@ const LEAF_TYPES = new Set<string>([
'template_literal',
]);

type ZodShape = Record<string, z.ZodTypeAny>;

function mergeIntersectionShapes(left: ZodShape, right: ZodShape): ZodShape {
const merged = { ...left };
for (const [key, value] of Object.entries(right)) {
merged[key] = Object.hasOwn(merged, key) ? z.intersection(merged[key], value) : value;
}
return merged;
}

function getIntersectionObjectShape(schema: z.ZodTypeAny): ZodShape | null {
if (schema.type === 'object') {
return (schema as z.ZodObject).shape;
}
if (schema.type !== 'intersection') {
return null;
}

const intersection = schema as z.ZodIntersection;
const left = getIntersectionObjectShape(intersection.def.left as z.ZodTypeAny);
const right = getIntersectionObjectShape(intersection.def.right as z.ZodTypeAny);
return left && right ? mergeIntersectionShapes(left, right) : null;
}

export function deepStrict<T extends z.ZodType>(schema: T): z.ZodType<z.infer<T>> {
const anySchema = schema as unknown as z.ZodTypeAny;
switch (anySchema.type) {
Expand Down Expand Up @@ -80,6 +105,18 @@ export function deepStrict<T extends z.ZodType>(schema: T): z.ZodType<z.infer<T>
strictOptions as unknown as readonly [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]]
) as unknown as z.ZodType<z.infer<T>>;
}
case 'intersection': {
const objectShape = getIntersectionObjectShape(anySchema);
if (objectShape) {
return deepStrict(z.object(objectShape)) as unknown as z.ZodType<z.infer<T>>;
}

const intersection = anySchema as z.ZodIntersection;
return z.intersection(
deepStrict(intersection.def.left as z.ZodTypeAny),
deepStrict(intersection.def.right as z.ZodTypeAny)
) as unknown as z.ZodType<z.infer<T>>;
}
default: {
const type = anySchema.type;
if (LEAF_TYPES.has(type)) {
Expand Down
Loading