Skip to content
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## Version 26

### v26.3.0

- Supporting several latest features of Zod 4.2 and 4.3 by the `Integration` generator:
- `z.looseRecord()` schema;
- Object properties wrapped by `z.exactOptional()` or created by `ZodType::exactOptional()` method.

### v26.2.0

- Ability to specify a custom name for a schema in the generated Documentation:
Expand Down
10 changes: 8 additions & 2 deletions express-zod-api/src/typescript-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,9 +186,15 @@ export class TypescriptAPI {
value: Typeable,
{
isOptional,
hasUndefined = isOptional,
isDeprecated,
comment,
}: { isOptional?: boolean; isDeprecated?: boolean; comment?: string } = {},
}: {
isOptional?: boolean;
hasUndefined?: boolean;
isDeprecated?: boolean;
comment?: string;
} = {},
) => {
const propType = this.ensureTypeNode(value);
const node = this.f.createPropertySignature(
Expand All @@ -197,7 +203,7 @@ export class TypescriptAPI {
isOptional
? this.f.createToken(this.ts.SyntaxKind.QuestionToken)
: undefined,
isOptional
hasUndefined
? this.makeUnion([
propType,
this.ensureTypeNode(this.ts.SyntaxKind.UndefinedKeyword),
Expand Down
22 changes: 18 additions & 4 deletions express-zod-api/src/zts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,15 @@ const onObject: Producer = (
([key, value]) => {
const { description: comment, deprecated: isDeprecated } =
globalRegistry.get(value) || {};
const isOptional =
(isResponse ? value._zod.optout : value._zod.optin) === "optional";
const hasUndefined =
isOptional && !(value instanceof z.core.$ZodExactOptional);
return api.makeInterfaceProp(key, next(value), {
comment,
isDeprecated,
isOptional:
(isResponse ? value._zod.optout : value._zod.optin) === "optional",
isOptional,
hasUndefined,
});
},
);
Expand Down Expand Up @@ -124,7 +128,16 @@ const onTuple: Producer = (
const onRecord: Producer = (
{ _zod: { def } }: z.core.$ZodRecord,
{ next, api },
) => api.ensureTypeNode("Record", [def.keyType, def.valueType].map(next));
) => {
const [keyNode, valueNode] = [def.keyType, def.valueType].map(next);
const primary = api.ensureTypeNode("Record", [keyNode, valueNode]);
const isLoose = def.mode === "loose";
if (!isLoose) return primary;
return api.f.createIntersectionTypeNode([
primary,
api.ensureTypeNode("Record", ["PropertyKey", valueNode]),
]);
};

const intersect = R.tryCatch(
(api: TypescriptAPI, nodes: ts.TypeNode[]) => {
Expand Down Expand Up @@ -170,7 +183,8 @@ const onWrapped: Producer = (
| z.core.$ZodCatch
| z.core.$ZodDefault
| z.core.$ZodOptional
| z.core.$ZodNonOptional,
| z.core.$ZodNonOptional
| z.core.$ZodExactOptional,
{ next },
) => next(def.innerType);

Expand Down
9 changes: 9 additions & 0 deletions express-zod-api/tests/__snapshots__/zts.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ exports[`zod-to-ts > Example > should produce the expected results 1`] = `
] | bigint)[];
};
}>;
looseRecord: Record<"one" | "two", boolean> & Record<PropertyKey, boolean>;
map: any;
set: any;
intersection: (string & number) | bigint;
Expand All @@ -76,6 +77,13 @@ exports[`zod-to-ts > Example > should produce the expected results 1`] = `
catch: number;
pipeline: string;
readonly: string;
extended: {
hex: string;
hash: string;
};
codec: string;
slug: string;
xor: string | number;
}"
`;

Expand Down Expand Up @@ -283,6 +291,7 @@ exports[`zod-to-ts > z.optional() > Zod 4: should add question mark only to opti
required: string;
}
] | undefined;
exact?: string;
}"
`;

Expand Down
9 changes: 9 additions & 0 deletions express-zod-api/tests/zts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ describe("zod-to-ts", () => {
}),
}),
),
looseRecord: z.looseRecord(z.literal(["one", "two"]), z.boolean()),
map: z.map(z.string(), z.array(z.object({ string: z.string() }))),
set: z.set(z.string()),
intersection: z.intersection(z.string(), z.number()).or(z.bigint()),
Expand All @@ -171,6 +172,13 @@ describe("zod-to-ts", () => {
catch: z.number().catch(123),
pipeline: z.string().regex(/\d+/).transform(Number).pipe(z.number()),
readonly: z.string().readonly(),
extended: z.object({}).extend({ hex: z.hex(), hash: z.hash("sha256") }),
codec: z.codec(z.string(), z.number(), {
encode: String,
decode: Number,
}),
slug: z.string().slugify(),
xor: z.xor([z.string(), z.number()]),
});

test("should produce the expected results", () => {
Expand Down Expand Up @@ -199,6 +207,7 @@ describe("zod-to-ts", () => {
}),
])
.optional(),
exact: z.string().exactOptional(),
});

test("Zod 4: does not add undefined to it, unwrap as is", () => {
Expand Down