Skip to content

Commit 8615bdd

Browse files
feat(zui): handle object catchall in multiple transformers (#591)
1 parent c65ad9d commit 8615bdd

File tree

10 files changed

+193
-22
lines changed

10 files changed

+193
-22
lines changed

zui/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@bpinternal/zui",
3-
"version": "0.20.2",
3+
"version": "0.21.0",
44
"description": "A fork of Zod with additional features",
55
"type": "module",
66
"source": "./src/index.ts",

zui/src/transforms/common/json-schema.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,11 @@ type _ObjectSchema = util.Satisfies<
8282
{
8383
type: 'object'
8484
properties: { [key: string]: ZuiJsonSchema }
85+
additionalProperties?: ZuiJsonSchema
8586
required: string[]
8687
},
8788
JSONSchema7
88-
> // TODO: add support for unknownKeys ('passthrough' | 'strict' | 'strip')
89+
>
8990
type _TupleSchema = util.Satisfies<
9091
{ type: 'array'; items: ZuiJsonSchema[]; additionalItems?: ZuiJsonSchema },
9192
JSONSchema7

zui/src/transforms/json-schema-to-zui-next/index.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,42 @@ describe.concurrent('zuifromJsonSchemaNext', () => {
139139
assert(zSchema).toEqual(expected)
140140
})
141141

142+
test('should map ObjectSchema with additional properties NumberSchema to ZodObject', () => {
143+
const jSchema = buildSchema({
144+
type: 'object',
145+
properties: { name: { type: 'string' } },
146+
required: ['name'],
147+
additionalProperties: { type: 'number' },
148+
})
149+
const zSchema = fromJsonSchema(jSchema)
150+
const expected = z.object({ name: z.string() }).catchall(z.number())
151+
assert(zSchema).toEqual(expected)
152+
})
153+
154+
test('should map ObjectSchema with additional properties AnySchema to ZodObject', () => {
155+
const jSchema = buildSchema({
156+
type: 'object',
157+
properties: { name: { type: 'string' } },
158+
required: ['name'],
159+
additionalProperties: true,
160+
})
161+
const zSchema = fromJsonSchema(jSchema)
162+
const expected = z.object({ name: z.string() }).passthrough()
163+
assert(zSchema).toEqual(expected)
164+
})
165+
166+
test('should map ObjectSchema with additional properties NeverSchema to ZodObject', () => {
167+
const jSchema = buildSchema({
168+
type: 'object',
169+
properties: { name: { type: 'string' } },
170+
required: ['name'],
171+
additionalProperties: false,
172+
})
173+
const zSchema = fromJsonSchema(jSchema)
174+
const expected = z.object({ name: z.string() }).strict()
175+
assert(zSchema).toEqual(expected)
176+
})
177+
142178
test('should map UnionSchema to ZodUnion', () => {
143179
const jSchema = buildSchema({ anyOf: [{ type: 'string' }, { type: 'number' }] })
144180
const zSchema = fromJsonSchema(jSchema)

zui/src/transforms/json-schema-to-zui-next/index.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -126,11 +126,9 @@ function _fromJsonSchema(schema: JSONSchema7Definition | undefined): z.ZodType {
126126

127127
if (schema.type === 'object') {
128128
if (schema.additionalProperties !== undefined && schema.properties !== undefined) {
129-
// TODO: should be supported with the .catchall() method but the behavior of this method is inconsistent
130-
throw new errors.UnsupportedJSONSchemaToZuiError({
131-
additionalProperties: schema.additionalProperties,
132-
properties: schema.properties,
133-
})
129+
const catchAll = _fromJsonSchema(schema.additionalProperties)
130+
const inner = _fromJsonSchema({ ...schema, additionalProperties: undefined }) as z.ZodObject
131+
return inner.catchall(catchAll)
134132
}
135133

136134
if (schema.properties !== undefined) {

zui/src/transforms/zui-to-json-schema-next/index.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,36 @@ describe('zuiToJsonSchemaNext', () => {
9595
})
9696
})
9797

98+
test('should map strict ZodObject to ObjectSchema with addtionalProperties never', () => {
99+
const schema = toJsonSchema(z.object({ name: z.string() }).strict())
100+
expect(schema).toEqual({
101+
type: 'object',
102+
properties: { name: { type: 'string' } },
103+
required: ['name'],
104+
additionalProperties: { not: true },
105+
})
106+
})
107+
108+
test('should map passthrough ZodObject to ObjectSchema with addtionalProperties any', () => {
109+
const schema = toJsonSchema(z.object({ name: z.string() }).passthrough())
110+
expect(schema).toEqual({
111+
type: 'object',
112+
properties: { name: { type: 'string' } },
113+
required: ['name'],
114+
additionalProperties: {},
115+
})
116+
})
117+
118+
test('should map ZodObject with catchall ZodNumber to ObjectSchema with addtionalProperties number', () => {
119+
const schema = toJsonSchema(z.object({ name: z.string() }).catchall(z.number()))
120+
expect(schema).toEqual({
121+
type: 'object',
122+
properties: { name: { type: 'string' } },
123+
required: ['name'],
124+
additionalProperties: { type: 'number' },
125+
})
126+
})
127+
98128
test('should map ZodUnion to UnionSchema', () => {
99129
const schema = toJsonSchema(z.union([z.string(), z.number()]))
100130
expect(schema).toEqual({

zui/src/transforms/zui-to-json-schema-next/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,15 @@ export function toJsonSchema(schema: z.Schema): json.ZuiJsonSchema {
7575
.map(([key, value]) => [key, _toRequired(value)] satisfies [string, z.ZodType])
7676
.map(([key, value]) => [key, toJsonSchema(value)] satisfies [string, json.ZuiJsonSchema])
7777

78-
// TODO: ensure unknownKeys and catchall are not lost
78+
const zAdditionalProperties = (schema as z.ZodObject).additionalProperties()
79+
const additionalProperties = zAdditionalProperties ? toJsonSchema(zAdditionalProperties) : undefined
7980

8081
return {
8182
type: 'object',
8283
properties: Object.fromEntries(properties),
8384
required,
8485
'x-zui': def['x-zui'],
86+
additionalProperties,
8587
} satisfies json.ObjectSchema
8688

8789
case z.ZodFirstPartyTypeKind.ZodUnion:

zui/src/transforms/zui-to-typescript-schema/index.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,37 @@ describe.concurrent('toTypescriptZuiString', () => {
301301
})
302302
assert(schema).toGenerateItself()
303303
})
304+
305+
test('strict object', () => {
306+
const schema = z
307+
.object({
308+
a: z.string(),
309+
b: z.number(),
310+
})
311+
.strict()
312+
assert(schema).toGenerateItself()
313+
})
314+
315+
test('passthrough object', () => {
316+
const schema = z
317+
.object({
318+
a: z.string(),
319+
b: z.number(),
320+
})
321+
.passthrough()
322+
assert(schema).toGenerateItself()
323+
})
324+
325+
test('catchall object', () => {
326+
const schema = z
327+
.object({
328+
a: z.string(),
329+
b: z.number(),
330+
})
331+
.catchall(z.boolean())
332+
assert(schema).toGenerateItself()
333+
})
334+
304335
test('union', () => {
305336
const schema = z.union([z.string(), z.number(), z.boolean()])
306337
assert(schema).toGenerateItself()

zui/src/transforms/zui-to-typescript-schema/index.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,17 +72,14 @@ function sUnwrapZod(schema: z.Schema): string {
7272
return `z.array(${sUnwrapZod(def.type)})${generateArrayChecks(def)}${_addZuiExtensions(def)}${_maybeDescribe(def)}`
7373

7474
case z.ZodFirstPartyTypeKind.ZodObject:
75-
const props = mapValues(def.shape(), (value) => {
76-
if (value instanceof z.Schema) {
77-
return sUnwrapZod(value)
78-
}
79-
return `z.any()`
80-
})
75+
const props = mapValues(def.shape(), sUnwrapZod)
76+
const catchall = (schema as z.ZodObject).additionalProperties()
77+
const catchallString = catchall ? `.catchall(${sUnwrapZod(catchall)})` : ''
8178
return [
8279
//
8380
`z.object({`,
8481
...Object.entries(props).map(([key, value]) => ` ${key}: ${value},`),
85-
`})${_addZuiExtensions(def)}${_maybeDescribe(def)}`,
82+
`})${catchallString}${_addZuiExtensions(def)}${_maybeDescribe(def)}`,
8683
]
8784
.join('\n')
8885
.trim()

zui/src/z/is-equal.test.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,40 @@ describe('isEqual', () => {
178178
}),
179179
)
180180
})
181+
test('strict object', () => {
182+
expectZui(
183+
z
184+
.object({
185+
a: z.string(),
186+
b: z.number(),
187+
})
188+
.strict(),
189+
).toEqual(
190+
z
191+
.object({
192+
b: z.number(),
193+
a: z.string(),
194+
})
195+
.catchall(z.never()),
196+
)
197+
})
198+
test('passthrough object', () => {
199+
expectZui(
200+
z
201+
.object({
202+
a: z.string(),
203+
b: z.number(),
204+
})
205+
.passthrough(),
206+
).toEqual(
207+
z
208+
.object({
209+
b: z.number(),
210+
a: z.string(),
211+
})
212+
.catchall(z.any()),
213+
)
214+
})
181215
test('optional', () => {
182216
expectZui(z.string().optional()).toEqual(z.optional(z.string()))
183217
})
@@ -280,5 +314,21 @@ describe('isNotEqual', () => {
280314
expectZui(z.bigint().min(min1)).not.toEqual(z.bigint().min(min2))
281315
})
282316

283-
// TODO: add more not equal tests
317+
test('object with different catchall', () => {
318+
expectZui(
319+
z
320+
.object({
321+
a: z.string(),
322+
b: z.number(),
323+
})
324+
.catchall(z.string()),
325+
).not.toEqual(
326+
z
327+
.object({
328+
a: z.string(),
329+
b: z.number(),
330+
})
331+
.catchall(z.number()),
332+
)
333+
})
284334
})

zui/src/z/types/object/index.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ import {
2626
errorUtil,
2727
partialUtil,
2828
createZodEnum,
29-
ZodUnknown,
3029
ZodNever,
30+
ZodAny,
3131
} from '../index'
3232
import { CustomSet } from '../utils/custom-set'
3333

@@ -75,6 +75,14 @@ export type UnknownKeysOutputType<T extends UnknownKeysParam> = T extends ZodTyp
7575
? { [k: string]: unknown }
7676
: {}
7777

78+
export type AdditionalProperties<T extends UnknownKeysParam> = T extends ZodTypeAny
79+
? T
80+
: T extends 'passthrough'
81+
? ZodAny
82+
: T extends 'strict'
83+
? ZodNever
84+
: undefined
85+
7886
export type deoptional<T extends ZodTypeAny> =
7987
T extends ZodOptional<infer U> ? deoptional<U> : T extends ZodNullable<infer U> ? ZodNullable<deoptional<U>> : T
8088

@@ -280,6 +288,22 @@ export class ZodObject<
280288
})
281289
}
282290

291+
/**
292+
* @returns The ZodType that is used to validate additional properties or undefined if extra keys are stripped.
293+
*/
294+
additionalProperties(): AdditionalProperties<UnknownKeys> {
295+
if (this._def.unknownKeys instanceof ZodType) {
296+
return this._def.unknownKeys as AdditionalProperties<UnknownKeys>
297+
}
298+
if (this._def.unknownKeys === 'passthrough') {
299+
return ZodAny.create() as AdditionalProperties<UnknownKeys>
300+
}
301+
if (this._def.unknownKeys === 'strict') {
302+
return ZodNever.create() as AdditionalProperties<UnknownKeys>
303+
}
304+
return undefined as AdditionalProperties<UnknownKeys>
305+
}
306+
283307
/**
284308
* @deprecated In most cases, this is no longer needed - unknown properties are now silently stripped.
285309
* If you want to pass through unknown properties, use `.passthrough()` instead.
@@ -579,7 +603,7 @@ export class ZodObject<
579603

580604
isEqual(schema: ZodType): boolean {
581605
if (!(schema instanceof ZodObject)) return false
582-
if (!this._unknownKeysEqual(schema._def.unknownKeys)) return false
606+
if (!this._unknownKeysEqual(schema)) return false
583607

584608
const thisShape = this._def.shape()
585609
const thatShape = schema._def.shape()
@@ -592,11 +616,13 @@ export class ZodObject<
592616
return thisProps.isEqual(thatProps)
593617
}
594618

595-
private _unknownKeysEqual(that: UnknownKeysParam): boolean {
596-
if (this._def.unknownKeys instanceof ZodType && that instanceof ZodType) {
597-
return this._def.unknownKeys.isEqual(that)
619+
private _unknownKeysEqual(that: ZodObject): boolean {
620+
const thisAdditionalProperties = this.additionalProperties()
621+
const thatAdditionalProperties = that.additionalProperties()
622+
if (thisAdditionalProperties === undefined || thatAdditionalProperties === undefined) {
623+
return thisAdditionalProperties === thatAdditionalProperties
598624
}
599-
return this._def.unknownKeys === that
625+
return thisAdditionalProperties.isEqual(thatAdditionalProperties)
600626
}
601627

602628
static create = <T extends ZodRawShape>(shape: T, params?: RawCreateParams): ZodObject<T, 'strip'> => {

0 commit comments

Comments
 (0)