Skip to content

Commit 41ea0c9

Browse files
authored
fix(zod): properly infer scalar array types (#2500)
1 parent ece062f commit 41ea0c9

File tree

4 files changed

+104
-37
lines changed

4 files changed

+104
-37
lines changed

packages/zod/src/types.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
GetTypeDefs,
1515
ModelFieldIsOptional,
1616
SchemaDef,
17+
TypeDefFieldIsArray,
1718
TypeDefFieldIsOptional,
1819
} from '@zenstackhq/schema';
1920
import type Decimal from 'decimal.js';
@@ -24,7 +25,7 @@ export type GetModelFieldsShape<Schema extends SchemaDef, Model extends GetModel
2425
[Field in GetModelFields<Schema, Model> as FieldIsRelation<Schema, Model, Field> extends true
2526
? never
2627
: Field]: ZodOptionalAndNullableIf<
27-
MapModelFieldToZod<Schema, Model, Field>,
28+
ZodArrayIf<MapModelFieldToZod<Schema, Model, Field>, FieldIsArray<Schema, Model, Field>>,
2829
ModelFieldIsOptional<Schema, Model, Field>
2930
>;
3031
} & {
@@ -58,7 +59,10 @@ export type GetModelCreateFieldsShape<Schema extends SchemaDef, Model extends Ge
5859
: FieldIsDelegateDiscriminator<Schema, Model, Field> extends true
5960
? never
6061
: Field]: ZodOptionalIf<
61-
ZodOptionalAndNullableIf<MapModelFieldToZod<Schema, Model, Field>, ModelFieldIsOptional<Schema, Model, Field>>,
62+
ZodOptionalAndNullableIf<
63+
ZodArrayIf<MapModelFieldToZod<Schema, Model, Field>, FieldIsArray<Schema, Model, Field>>,
64+
ModelFieldIsOptional<Schema, Model, Field>
65+
>,
6266
FieldHasDefault<Schema, Model, Field>
6367
>;
6468
};
@@ -71,13 +75,16 @@ export type GetModelUpdateFieldsShape<Schema extends SchemaDef, Model extends Ge
7175
: FieldIsDelegateDiscriminator<Schema, Model, Field> extends true
7276
? never
7377
: Field]: z.ZodOptional<
74-
ZodOptionalAndNullableIf<MapModelFieldToZod<Schema, Model, Field>, ModelFieldIsOptional<Schema, Model, Field>>
78+
ZodOptionalAndNullableIf<
79+
ZodArrayIf<MapModelFieldToZod<Schema, Model, Field>, FieldIsArray<Schema, Model, Field>>,
80+
ModelFieldIsOptional<Schema, Model, Field>
81+
>
7582
>;
7683
};
7784

7885
export type GetTypeDefFieldsShape<Schema extends SchemaDef, Type extends GetTypeDefs<Schema>> = {
7986
[Field in GetTypeDefFields<Schema, Type>]: ZodOptionalAndNullableIf<
80-
MapTypeDefFieldToZod<Schema, Type, Field>,
87+
ZodArrayIf<MapTypeDefFieldToZod<Schema, Type, Field>, TypeDefFieldIsArray<Schema, Type, Field>>,
8188
TypeDefFieldIsOptional<Schema, Type, Field>
8289
>;
8390
};

packages/zod/test/factory.test.ts

Lines changed: 69 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ const validPost = {
3131
title: 'My First Post',
3232
published: true,
3333
authorId: null,
34+
tags: ['announcement', 'update'],
3435
};
3536

3637
describe('SchemaFactory - makeModelSchema', () => {
@@ -103,6 +104,19 @@ describe('SchemaFactory - makeModelSchema', () => {
103104
// optional scalar (foreign key)
104105
expectTypeOf<Post['authorId']>().toEqualTypeOf<string | null | undefined>();
105106

107+
// scalar array
108+
expectTypeOf<Post['tags']>().toEqualTypeOf<string[]>();
109+
110+
const createPostSchema = factory.makeModelCreateSchema('Post');
111+
type PostCreate = z.infer<typeof createPostSchema>;
112+
113+
expectTypeOf<PostCreate['tags']>().toEqualTypeOf<string[]>();
114+
115+
const updatePostSchema = factory.makeModelUpdateSchema('Post');
116+
type PostUpdate = z.infer<typeof updatePostSchema>;
117+
118+
expectTypeOf<PostUpdate['tags']>().toEqualTypeOf<string[] | undefined>();
119+
106120
// optional relation field present in type
107121
expectTypeOf<Post>().toHaveProperty('author');
108122
const _userSchema = factory.makeModelSchema('User');
@@ -362,7 +376,7 @@ describe('SchemaFactory - makeModelSchema', () => {
362376
const userSchema = factory.makeModelSchema('User');
363377
const result = userSchema.safeParse({
364378
...validUser,
365-
address: { street: '123 Main St', city: 'Springfield', zip: null },
379+
address: { residents: [], street: '123 Main St', city: 'Springfield', zip: null },
366380
});
367381
expect(result.success).toBe(true);
368382
});
@@ -371,7 +385,7 @@ describe('SchemaFactory - makeModelSchema', () => {
371385
const userSchema = factory.makeModelSchema('User');
372386
const result = userSchema.safeParse({
373387
...validUser,
374-
address: { street: '123 Main St', city: 'Springfield', zip: '12345' },
388+
address: { residents: [], street: '123 Main St', city: 'Springfield', zip: '12345' },
375389
});
376390
expect(result.success).toBe(true);
377391
});
@@ -380,7 +394,7 @@ describe('SchemaFactory - makeModelSchema', () => {
380394
const userSchema = factory.makeModelSchema('User');
381395
const result = userSchema.safeParse({
382396
...validUser,
383-
address: { street: '123 Main St', city: 'Springfield', zip: null, extra: 'field' },
397+
address: { residents: [], street: '123 Main St', city: 'Springfield', zip: null, extra: 'field' },
384398
});
385399
expect(result.success).toBe(false);
386400
});
@@ -389,7 +403,7 @@ describe('SchemaFactory - makeModelSchema', () => {
389403
const userSchema = factory.makeModelSchema('User');
390404
const result = userSchema.safeParse({
391405
...validUser,
392-
address: { street: '123 Main St' },
406+
address: { residents: [], street: '123 Main St' },
393407
});
394408
expect(result.success).toBe(false);
395409
});
@@ -440,18 +454,21 @@ describe('SchemaFactory - makeModelSchema', () => {
440454
describe('SchemaFactory - makeTypeSchema', () => {
441455
it('generates schema for Address typedef', () => {
442456
const addressSchema = factory.makeTypeSchema('Address');
443-
expect(addressSchema.safeParse({ street: '123 Main', city: 'Springfield', zip: null }).success).toBe(true);
457+
expect(
458+
addressSchema.safeParse({ residents: [], street: '123 Main', city: 'Springfield', zip: null }).success,
459+
).toBe(true);
444460
});
445461

446462
it('rejects Address with missing required field', () => {
447463
const addressSchema = factory.makeTypeSchema('Address');
448-
const result = addressSchema.safeParse({ street: '123 Main' });
464+
const result = addressSchema.safeParse({ residents: [], street: '123 Main' });
449465
expect(result.success).toBe(false);
450466
});
451467

452468
it('rejects Address with extra fields (strict object)', () => {
453469
const addressSchema = factory.makeTypeSchema('Address');
454470
const result = addressSchema.safeParse({
471+
residents: [],
455472
street: '123 Main',
456473
city: 'Springfield',
457474
zip: null,
@@ -462,47 +479,71 @@ describe('SchemaFactory - makeTypeSchema', () => {
462479

463480
it('accepts Address with optional zip as null', () => {
464481
const addressSchema = factory.makeTypeSchema('Address');
465-
expect(addressSchema.safeParse({ street: '123 Main', city: 'Springfield', zip: null }).success).toBe(true);
482+
expect(
483+
addressSchema.safeParse({ residents: [], street: '123 Main', city: 'Springfield', zip: null }).success,
484+
).toBe(true);
466485
});
467486

468487
it('accepts Address with optional zip as a string', () => {
469488
const addressSchema = factory.makeTypeSchema('Address');
470-
expect(addressSchema.safeParse({ street: '123 Main', city: 'Springfield', zip: '12345' }).success).toBe(true);
489+
expect(
490+
addressSchema.safeParse({ residents: [], street: '123 Main', city: 'Springfield', zip: '12345' }).success,
491+
).toBe(true);
471492
});
472493

473494
describe('extra validations', () => {
474495
it('passes when zip is null', () => {
475496
const addressSchema = factory.makeTypeSchema('Address');
476-
expect(addressSchema.safeParse({ street: '123 Main', city: 'Springfield', zip: null }).success).toBe(true);
497+
expect(
498+
addressSchema.safeParse({ residents: [], street: '123 Main', city: 'Springfield', zip: null }).success,
499+
).toBe(true);
477500
});
478501

479502
it('passes when zip is omitted', () => {
480503
const addressSchema = factory.makeTypeSchema('Address');
481-
expect(addressSchema.safeParse({ street: '123 Main', city: 'Springfield' }).success).toBe(true);
504+
expect(addressSchema.safeParse({ residents: [], street: '123 Main', city: 'Springfield' }).success).toBe(
505+
true,
506+
);
482507
});
483508

484509
it('passes when zip is exactly 5 characters', () => {
485510
const addressSchema = factory.makeTypeSchema('Address');
486-
expect(addressSchema.safeParse({ street: '123 Main', city: 'Springfield', zip: '90210' }).success).toBe(
487-
true,
488-
);
511+
expect(
512+
addressSchema.safeParse({ residents: [], street: '123 Main', city: 'Springfield', zip: '90210' })
513+
.success,
514+
).toBe(true);
489515
});
490516

491517
it('fails when zip is fewer than 5 characters', () => {
492518
const addressSchema = factory.makeTypeSchema('Address');
493-
const result = addressSchema.safeParse({ street: '123 Main', city: 'Springfield', zip: '123' });
519+
const result = addressSchema.safeParse({
520+
residents: [],
521+
street: '123 Main',
522+
city: 'Springfield',
523+
zip: '123',
524+
});
494525
expect(result.success).toBe(false);
495526
});
496527

497528
it('fails when zip is more than 5 characters', () => {
498529
const addressSchema = factory.makeTypeSchema('Address');
499-
const result = addressSchema.safeParse({ street: '123 Main', city: 'Springfield', zip: '123456' });
530+
const result = addressSchema.safeParse({
531+
residents: [],
532+
street: '123 Main',
533+
city: 'Springfield',
534+
zip: '123456',
535+
});
500536
expect(result.success).toBe(false);
501537
});
502538

503539
it('error message matches the configured message', () => {
504540
const addressSchema = factory.makeTypeSchema('Address');
505-
const result = addressSchema.safeParse({ street: '123 Main', city: 'Springfield', zip: '123' });
541+
const result = addressSchema.safeParse({
542+
residents: [],
543+
street: '123 Main',
544+
city: 'Springfield',
545+
zip: '123',
546+
});
506547
expect(result.success).toBe(false);
507548
if (!result.success) {
508549
expect(result.error.issues.map((i) => i.message)).toContain('Zip code must be exactly 5 characters');
@@ -511,7 +552,12 @@ describe('SchemaFactory - makeTypeSchema', () => {
511552

512553
it('error path points to the zip field', () => {
513554
const addressSchema = factory.makeTypeSchema('Address');
514-
const result = addressSchema.safeParse({ street: '123 Main', city: 'Springfield', zip: '123' });
555+
const result = addressSchema.safeParse({
556+
residents: [],
557+
street: '123 Main',
558+
city: 'Springfield',
559+
zip: '123',
560+
});
515561
expect(result.success).toBe(false);
516562
if (!result.success) {
517563
expect(result.error.issues.map((i) => i.path)).toContainEqual(['zip']);
@@ -520,7 +566,7 @@ describe('SchemaFactory - makeTypeSchema', () => {
520566

521567
it('fails when city is too short', () => {
522568
const addressSchema = factory.makeTypeSchema('Address');
523-
const result = addressSchema.safeParse({ street: '123 Main', city: '', zip: '12345' });
569+
const result = addressSchema.safeParse({ residents: [], street: '123 Main', city: '', zip: '12345' });
524570
expect(result.success).toBe(false);
525571
});
526572

@@ -541,12 +587,14 @@ describe('SchemaFactory - makeTypeSchema', () => {
541587
avatar: null,
542588
metadata: null,
543589
status: 'ACTIVE',
544-
address: { street: '123 Main', city: 'Springfield', zip: '90210' },
590+
address: { residents: [], street: '123 Main', city: 'Springfield', zip: '90210' },
545591
};
546592
expect(userSchema.safeParse(validUser).success).toBe(true);
547593
expect(
548-
userSchema.safeParse({ ...validUser, address: { street: '123 Main', city: 'Springfield', zip: '123' } })
549-
.success,
594+
userSchema.safeParse({
595+
...validUser,
596+
address: { residents: ['Alice'], street: '123 Main', city: 'Springfield', zip: '123' },
597+
}).success,
550598
).toBe(false);
551599
});
552600
});

packages/zod/test/schema/schema.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import { type SchemaDef, ExpressionUtils } from "@zenstackhq/schema";
99
export class SchemaType implements SchemaDef {
1010
provider = {
11-
type: "sqlite"
11+
type: "postgresql"
1212
} as const;
1313
models = {
1414
User: {
@@ -125,6 +125,11 @@ export class SchemaType implements SchemaDef {
125125
name: "published",
126126
type: "Boolean"
127127
},
128+
tags: {
129+
name: "tags",
130+
type: "String",
131+
array: true
132+
},
128133
author: {
129134
name: "author",
130135
type: "User",
@@ -302,6 +307,11 @@ export class SchemaType implements SchemaDef {
302307
Address: {
303308
name: "Address",
304309
fields: {
310+
residents: {
311+
name: "residents",
312+
type: "String",
313+
array: true
314+
},
305315
street: {
306316
name: "street",
307317
type: "String",

packages/zod/test/schema/schema.zmodel

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
datasource db {
2-
provider = 'sqlite'
2+
provider = 'postgresql'
33
}
44

55
enum Status {
@@ -11,9 +11,10 @@ enum Status {
1111
}
1212

1313
type Address {
14-
street String @meta("description", "Street address line")
15-
city String @length(2)
16-
zip String?
14+
residents String[]
15+
street String @meta("description", "Street address line")
16+
city String @length(2)
17+
zip String?
1718

1819
@@validate(zip == null || length(zip) == 5, "Zip code must be exactly 5 characters", ["zip"])
1920
@@meta("description", "A mailing address")
@@ -42,20 +43,21 @@ model User {
4243
}
4344

4445
model Post {
45-
id String @id @default(cuid())
46+
id String @id @default(cuid())
4647
title String
4748
published Boolean
48-
author User? @relation(fields: [authorId], references: [id])
49+
tags String[]
50+
author User? @relation(fields: [authorId], references: [id])
4951
authorId String?
5052
}
5153

5254
// --- Computed fields ---
5355
model Product {
54-
id String @id @default(cuid())
55-
name String
56-
price Float
57-
discount Float @default(0)
58-
finalPrice Float @computed
56+
id String @id @default(cuid())
57+
name String
58+
price Float
59+
discount Float @default(0)
60+
finalPrice Float @computed
5961
}
6062

6163
// --- Delegate models ---

0 commit comments

Comments
 (0)