Skip to content

Commit 1f6a943

Browse files
authored
Better client types producers (#757)
* Ref: moving hasCoercion() to common helpers. * Aligning optionals in onObject with OpenAPI generator logic. * Minor ref depictEffect, using public methods where possible. * Sample of handling transformations. * Ref: syntax. * Extracting makeSample(). * Ref: extracting tryToTransform() helper. Removing legacy props inherited from inputs of transformation in depictEffect. * Adjusting and covering edge cases. Depict transformation as any when failed to figure out its type. * Changelog: the future version 8.9.0.
1 parent d376e70 commit 1f6a943

File tree

10 files changed

+195
-76
lines changed

10 files changed

+195
-76
lines changed

CHANGELOG.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22

33
## Version 8
44

5+
### v8.9.0
6+
7+
- Fixes of the documentation generator (OpenAPI).
8+
- Transformations in the `output` schema:
9+
- If failed to figure out their output type, now depicted as `any`.
10+
- No excessive properties are inherited from their input types.
11+
- Improvements of the frontend client generator
12+
- Achieving the similarity with the OpenAPI generator.
13+
- Transformations in the `output` schema are not recognized and typed, similar to OpenAPI generator.
14+
- The `coerce` feature in output schema now does not lead to marking the property as optional.
15+
516
### v8.8.2
617

718
- No new features, no any fixes.
@@ -37,7 +48,7 @@ after:
3748
3849
### v8.8.0
3950
40-
- First step on generating better Typescript from your IO schemas.
51+
- First step on generating better types from your IO schemas for the frontend client.
4152
- I rewrote and refactored the functionality of `zod-to-ts` within the library.
4253
- Using the abstract schema walker I made in the previous release.
4354
- In general, I'm aiming to achieve the consistency between OpenAPI and Client generators.

src/common-helpers.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,32 @@ export function hasUpload(schema: z.ZodTypeAny): boolean {
210210
return false;
211211
}
212212

213+
/**
214+
* @desc isNullable() and isOptional() validate the schema's input
215+
* @desc They always return true in case of coercion, which should be taken into account when depicting response
216+
*/
217+
export const hasCoercion = (schema: z.ZodType): boolean =>
218+
"coerce" in schema._def && typeof schema._def.coerce === "boolean"
219+
? schema._def.coerce
220+
: false;
221+
222+
export const tryToTransform = ({
223+
effect,
224+
sample,
225+
}: {
226+
effect: z.Effect<any> & { type: "transform" };
227+
sample: any;
228+
}) => {
229+
try {
230+
return typeof effect.transform(sample, {
231+
addIssue: () => {},
232+
path: [],
233+
});
234+
} catch (e) {
235+
return undefined;
236+
}
237+
};
238+
213239
// obtaining the private helper type from Zod
214240
export type ErrMessage = Exclude<
215241
Parameters<typeof z.ZodString.prototype.email>[0],

src/open-api-helpers.ts

Lines changed: 31 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@ import {
1818
ArrayElement,
1919
getExamples,
2020
getRoutePathParams,
21+
hasCoercion,
2122
hasTopLevelTransformingEffect,
2223
routePathParamsRegex,
24+
tryToTransform,
2325
} from "./common-helpers";
2426
import { InputSources, TagsConfig } from "./config-type";
2527
import { ZodDateIn, isoDateRegex } from "./date-in-schema";
@@ -65,6 +67,19 @@ const shortDescriptionLimit = 50;
6567
const isoDateDocumentationUrl =
6668
"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString";
6769

70+
const samples: Record<
71+
Exclude<NonNullable<SchemaObject["type"]>, Array<any>>,
72+
any
73+
> = {
74+
integer: 0,
75+
number: 0,
76+
string: "",
77+
boolean: false,
78+
object: {},
79+
null: null,
80+
array: [],
81+
};
82+
6883
/* eslint-disable @typescript-eslint/no-use-before-define */
6984

7085
export const reformatParamsInPath = (path: string) =>
@@ -432,43 +447,29 @@ export const depictObjectProperties = ({
432447
);
433448
};
434449

450+
const makeSample = (depicted: SchemaObject) => {
451+
const type = (
452+
Array.isArray(depicted.type) ? depicted.type[0] : depicted.type
453+
) as keyof typeof samples;
454+
return samples?.[type];
455+
};
456+
435457
export const depictEffect: Depicter<z.ZodEffects<z.ZodTypeAny>> = ({
436458
schema,
437459
isResponse,
438460
next,
439461
}) => {
440-
const input = next({ schema: schema._def.schema });
441-
const effect = schema._def.effect;
442-
if (isResponse && effect && effect.type === "transform") {
443-
let output = "undefined";
444-
try {
445-
output = typeof effect.transform(
446-
["integer", "number"].includes(`${input.type}`)
447-
? 0
448-
: "string" === input.type
449-
? ""
450-
: "boolean" === input.type
451-
? false
452-
: "object" === input.type
453-
? {}
454-
: "null" === input.type
455-
? null
456-
: "array" === input.type
457-
? []
458-
: undefined,
459-
{ addIssue: () => {}, path: [] }
460-
);
461-
} catch (e) {
462-
/**/
462+
const input = next({ schema: schema.innerType() });
463+
const { effect } = schema._def;
464+
if (isResponse && effect.type === "transform") {
465+
const outputType = tryToTransform({ effect, sample: makeSample(input) });
466+
if (outputType && ["number", "string", "boolean"].includes(outputType)) {
467+
return { type: outputType as "number" | "string" | "boolean" };
468+
} else {
469+
return next({ schema: z.any() });
463470
}
464-
return {
465-
...input,
466-
...(["number", "string", "boolean"].includes(output) && {
467-
type: output as "number" | "string" | "boolean",
468-
}),
469-
};
470471
}
471-
if (!isResponse && effect && effect.type === "preprocess") {
472+
if (!isResponse && effect.type === "preprocess") {
472473
const { type: inputType, ...rest } = input;
473474
return {
474475
...rest,
@@ -630,15 +631,6 @@ export const depicters: HandlingRules<SchemaObject, OpenAPIContext> = {
630631
ZodPipeline: depictPipeline,
631632
};
632633

633-
/**
634-
* @desc isNullable() and isOptional() validate the schema's input
635-
* @desc They always return true in case of coercion, which should be taken into account when depicting response
636-
*/
637-
export const hasCoercion = (schema: z.ZodType): boolean =>
638-
"coerce" in schema._def && typeof schema._def.coerce === "boolean"
639-
? schema._def.coerce
640-
: false;
641-
642634
export const onEach: Depicter<z.ZodTypeAny, "last"> = ({
643635
schema,
644636
isResponse,

src/zts.ts

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
import ts from "typescript";
2828
import { z } from "zod";
29+
import { hasCoercion, tryToTransform } from "./common-helpers";
2930
import { HandlingRules, walkSchema } from "./schema-walker";
3031
import {
3132
LiteralType,
@@ -37,6 +38,16 @@ import {
3738

3839
const { factory: f } = ts;
3940

41+
const samples: Partial<Record<ts.KeywordTypeSyntaxKind, any>> = {
42+
[ts.SyntaxKind.AnyKeyword]: "",
43+
[ts.SyntaxKind.BigIntKeyword]: BigInt(0),
44+
[ts.SyntaxKind.BooleanKeyword]: false,
45+
[ts.SyntaxKind.NumberKeyword]: 0,
46+
[ts.SyntaxKind.ObjectKeyword]: {},
47+
[ts.SyntaxKind.StringKeyword]: "",
48+
[ts.SyntaxKind.UndefinedKeyword]: undefined,
49+
};
50+
4051
const onLiteral: Producer<z.ZodLiteral<LiteralType>> = ({
4152
schema: { value },
4253
}) =>
@@ -50,14 +61,16 @@ const onLiteral: Producer<z.ZodLiteral<LiteralType>> = ({
5061
: f.createStringLiteral(value)
5162
);
5263

53-
// @todo align treating optionals
5464
const onObject: Producer<z.ZodObject<z.ZodRawShape>> = ({
5565
schema: { shape },
66+
isResponse,
5667
next,
5768
}) => {
5869
const members = Object.entries(shape).map<ts.TypeElement>(([key, value]) => {
59-
const { typeName: propTypeName } = value._def;
60-
const isOptional = propTypeName === "ZodOptional" || value.isOptional();
70+
const isOptional =
71+
isResponse && hasCoercion(value)
72+
? value instanceof z.ZodOptional
73+
: value.isOptional();
6174
const propertySignature = f.createPropertySignature(
6275
undefined,
6376
makePropertyIdentifier(key),
@@ -92,9 +105,34 @@ const onSomeUnion: Producer<
92105
> = ({ schema: { options }, next }) =>
93106
f.createUnionTypeNode(options.map((option) => next({ schema: option })));
94107

95-
// @todo implement effects handling
96-
const onEffects: Producer<z.ZodEffects<any>> = ({ schema, next }) =>
97-
next({ schema: schema._def.schema });
108+
const makeSample = (produced: ts.TypeNode) =>
109+
samples?.[produced.kind as keyof typeof samples];
110+
111+
const onEffects: Producer<z.ZodEffects<z.ZodTypeAny>> = ({
112+
schema,
113+
next,
114+
isResponse,
115+
}) => {
116+
const input = next({ schema: schema.innerType() });
117+
const effect = schema._def.effect;
118+
if (isResponse && effect.type === "transform") {
119+
const outputType = tryToTransform({ effect, sample: makeSample(input) });
120+
const resolutions: Partial<
121+
Record<NonNullable<typeof outputType>, ts.KeywordTypeSyntaxKind>
122+
> = {
123+
number: ts.SyntaxKind.NumberKeyword,
124+
bigint: ts.SyntaxKind.BigIntKeyword,
125+
boolean: ts.SyntaxKind.BooleanKeyword,
126+
string: ts.SyntaxKind.StringKeyword,
127+
undefined: ts.SyntaxKind.UndefinedKeyword,
128+
object: ts.SyntaxKind.ObjectKeyword,
129+
};
130+
return f.createKeywordTypeNode(
131+
(outputType && resolutions[outputType]) || ts.SyntaxKind.AnyKeyword
132+
);
133+
}
134+
return input;
135+
};
98136

99137
const onNativeEnum: Producer<z.ZodNativeEnum<z.EnumLike>> = ({ schema }) =>
100138
f.createUnionTypeNode(

tests/unit/__snapshots__/open-api-helpers.spec.ts.snap

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,20 @@ exports[`Open API helpers depictEffect() should depict as string (preprocess) 1`
165165
}
166166
`;
167167

168+
exports[`Open API helpers depictEffect() should handle edge cases 1`] = `
169+
{
170+
"format": "any",
171+
"nullable": true,
172+
}
173+
`;
174+
175+
exports[`Open API helpers depictEffect() should handle edge cases 2`] = `
176+
{
177+
"format": "any",
178+
"nullable": true,
179+
}
180+
`;
181+
168182
exports[`Open API helpers depictEnum() should set type and enum properties 1`] = `
169183
{
170184
"enum": [

tests/unit/__snapshots__/open-api.spec.ts.snap

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -574,11 +574,6 @@ paths:
574574
properties:
575575
numericStr:
576576
type: string
577-
format: double
578-
minimum: 5e-324
579-
exclusiveMinimum: false
580-
maximum: 1.7976931348623157e+308
581-
exclusiveMaximum: false
582577
required:
583578
- numericStr
584579
example:
@@ -670,11 +665,6 @@ paths:
670665
properties:
671666
numericStr:
672667
type: string
673-
format: double
674-
minimum: 5e-324
675-
exclusiveMinimum: false
676-
maximum: 1.7976931348623157e+308
677-
exclusiveMaximum: false
678668
required:
679669
- numericStr
680670
example:
@@ -771,11 +761,6 @@ paths:
771761
properties:
772762
numericStr:
773763
type: string
774-
format: double
775-
minimum: 5e-324
776-
exclusiveMinimum: false
777-
maximum: 1.7976931348623157e+308
778-
exclusiveMaximum: false
779764
example: "123"
780765
required:
781766
- numericStr

tests/unit/__snapshots__/zts.spec.ts.snap

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,14 @@ exports[`zod-to-ts z.discriminatedUnion() outputs correct typescript 1`] = `
130130
}"
131131
`;
132132

133+
exports[`zod-to-ts z.effect() transformations should handle an error within the transformation 1`] = `"any"`;
134+
135+
exports[`zod-to-ts z.effect() transformations should handle unsupported transformation in response 1`] = `"any"`;
136+
137+
exports[`zod-to-ts z.effect() transformations should produce the schema type intact 1`] = `"number"`;
138+
139+
exports[`zod-to-ts z.effect() transformations should produce the schema type transformed 1`] = `"string"`;
140+
133141
exports[`zod-to-ts z.literal() Should produce the correct typescript 0 1`] = `""test""`;
134142

135143
exports[`zod-to-ts z.literal() Should produce the correct typescript 1 1`] = `"true"`;

tests/unit/common-helpers.spec.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
getMessageFromError,
99
getRoutePathParams,
1010
getStatusCodeFromError,
11+
hasCoercion,
1112
hasTopLevelTransformingEffect,
1213
hasUpload,
1314
isLoggerConfig,
@@ -501,4 +502,18 @@ describe("Common Helpers", () => {
501502
expect(result.message).toBe(expected);
502503
});
503504
});
505+
506+
describe("hasCoercion", () => {
507+
test.each([
508+
{ schema: z.string(), coercion: false },
509+
{ schema: z.coerce.string(), coercion: true },
510+
{ schema: z.boolean({ coerce: true }), coercion: true },
511+
{ schema: z.custom(), coercion: false },
512+
])(
513+
"should check the presence and value of coerce prop %#",
514+
({ schema, coercion }) => {
515+
expect(hasCoercion(schema)).toBe(coercion);
516+
}
517+
);
518+
});
504519
});

tests/unit/open-api-helpers.spec.ts

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@ import {
4848
excludeExampleFromDepiction,
4949
excludeParamsFromDepiction,
5050
extractObjectSchema,
51-
hasCoercion,
5251
onEach,
5352
onMissing,
5453
reformatParamsInPath,
@@ -312,20 +311,6 @@ describe("Open API helpers", () => {
312311
});
313312
});
314313

315-
describe("hasCoercion", () => {
316-
test.each([
317-
{ schema: z.string(), coercion: false },
318-
{ schema: z.coerce.string(), coercion: true },
319-
{ schema: z.boolean({ coerce: true }), coercion: true },
320-
{ schema: z.custom(), coercion: false },
321-
])(
322-
"should check the presence and value of coerce prop %#",
323-
({ schema, coercion }) => {
324-
expect(hasCoercion(schema)).toBe(coercion);
325-
}
326-
);
327-
});
328-
329314
describe("depictOptional()", () => {
330315
test.each<OpenAPIContext>([{ isResponse: false }, { isResponse: true }])(
331316
"should pass the next depicter %#",
@@ -614,6 +599,21 @@ describe("Open API helpers", () => {
614599
})
615600
).toMatchSnapshot();
616601
});
602+
603+
test.each([
604+
z.number().transform((num) => () => num),
605+
z.number().transform(() => {
606+
throw new Error("this should be handled");
607+
}),
608+
])("should handle edge cases", (schema) => {
609+
expect(
610+
depictEffect({
611+
schema,
612+
...responseContext,
613+
next: makeNext(responseContext),
614+
})
615+
).toMatchSnapshot();
616+
});
617617
});
618618

619619
describe("depictPipeline", () => {

0 commit comments

Comments
 (0)