Skip to content

Commit 65eb6c5

Browse files
committed
support for Maybe<T> special case
- we can configure if Maybe represents nullable, optional, or both. - we can define which generic types to use as Maybe (e.g. Maybe, InputMaybe, etc). Can be multiple. - when ts-to-zod encounters a generic type in the list of "Maybe"s, it skips the schema generation for them. - when it encounters them as being used, it makes a call to `maybe()` function. - the `maybe` function is defined depending on the nullable/optional config. This is useful to work in conjunction with other codegen tools, like graphql codegens. e.g. ```ts // config /** * ts-to-zod configuration. * * @type {import("./src/config").TsToZodConfig} */ module.exports = [ { name: "example", input: "example/heros.ts", output: "example/heros.zod.ts", maybeTypeNames: ["Maybe"], } ]; // input export type Maybe<T> = T | null | "whatever really"; // this is actually ignored export interface Superman { age: number; aliases: Maybe<string[]>; } // output export const maybe = <T extends z.ZodTypeAny>(schema: T) => { return schema.nullable(); }; export const supermanSchema = z.object({ age: z.number(), alias: maybe(z.array(z.string())) }); ``` Configuration: By default, this feature is turned off. When adding the list of type names to be considered 'Maybe's, we turn it on. Maybe is nullable and optional by default, unless specified otherwise. We can set this in CLI options... - `maybeOptional`: boolean, defaults to true - `maybeNullable`: boolean, defaults to true - `maybeTypeName`: string, multiple. List of type names. …as well as in the ts-to-zod config file. - `maybeOptional`: boolean - `maybeNullable`: boolean - `maybeTypeNames`: string[]. list of type names.
1 parent c5abe8c commit 65eb6c5

15 files changed

+465
-36
lines changed

.eslintrc.js

+2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
module.exports = {
55
parser: "@typescript-eslint/parser",
66

7+
ignorePatterns: [".idea/**/*", ".history/**/*"],
8+
79
parserOptions: {
810
ecmaVersion: 2020,
911
sourceType: "module",

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,5 @@ dist
77
build
88
lib
99
.vscode
10+
.idea
11+
.history

.prettierignore

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
.DS_Store
2+
node_modules
3+
coverage
4+
.nyc_output
5+
dist
6+
build
7+
lib
8+
.vscode
9+
.idea
10+
.history

example/heros.ts

+3
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,15 @@ export type SupermanEnemy = Superman["enemies"][-1];
2121
export type SupermanName = Superman["name"];
2222
export type SupermanInvinciblePower = Superman["powers"][2];
2323

24+
export type Maybe<T> = T | null | undefined;
25+
2426
export interface Superman {
2527
name: "superman" | "clark kent" | "kal-l";
2628
enemies: Record<string, Enemy>;
2729
age: number;
2830
underKryptonite?: boolean;
2931
powers: ["fly", "laser", "invincible"];
32+
counters?: Maybe<EnemyPower[]>;
3033
}
3134

3235
export interface Villain {

example/heros.zod.ts

+5
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
import { z } from "zod";
33
import { EnemyPower, Villain } from "./heros";
44

5+
export const maybe = <T extends z.ZodTypeAny>(schema: T) => {
6+
return schema.nullable().optional();
7+
};
8+
59
export const enemyPowerSchema = z.nativeEnum(EnemyPower);
610

711
export const skillsSpeedEnemySchema = z.object({
@@ -28,6 +32,7 @@ export const supermanSchema = z.object({
2832
z.literal("laser"),
2933
z.literal("invincible"),
3034
]),
35+
counters: maybe(z.array(enemyPowerSchema)).optional(),
3136
});
3237

3338
export const villainSchema: z.ZodSchema<Villain> = z.lazy(() =>

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,10 @@
2020
"scripts": {
2121
"build": "tsc -p tsconfig.package.json",
2222
"prepublishOnly": "yarn test:ci && rimraf lib && yarn build",
23-
"format": "eslint **/*.{js,jsx,ts,tsx} --fix && prettier **/*.{js,jsx,ts,tsx,json} --write",
23+
"format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix && prettier \"**/*.{js,jsx,ts,tsx,json}\" --write",
2424
"test": "jest",
2525
"test:ci": "jest --ci --coverage && yarn gen:all && tsc --noEmit",
26+
"type-check": "tsc --noEmit",
2627
"gen:all": "./bin/run --all",
2728
"gen:example": "./bin/run --config example",
2829
"gen:config": "./bin/run --config config",

src/cli.ts

+66-13
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { join, relative, parse } from "path";
66
import slash from "slash";
77
import ts from "typescript";
88
import { generate, GenerateProps } from "./core/generate";
9-
import { TsToZodConfig, Config } from "./config";
9+
import { TsToZodConfig, Config, MaybeConfig } from "./config";
1010
import {
1111
tsToZodConfigSchema,
1212
getSchemaNameSchema,
@@ -76,6 +76,19 @@ class TsToZod extends Command {
7676
char: "k",
7777
description: "Keep parameters comments",
7878
}),
79+
maybeOptional: flags.boolean({
80+
description:
81+
"treat Maybe<T> as optional (can be undefined). Can be combined with maybeNullable",
82+
}),
83+
maybeNullable: flags.boolean({
84+
description:
85+
"treat Maybe<T> as optional (can be null). Can be combined with maybeOptional",
86+
}),
87+
maybeTypeName: flags.string({
88+
multiple: true,
89+
description:
90+
"determines the name of the types to treat as 'Maybe'. Can be multiple.",
91+
}),
7992
init: flags.boolean({
8093
char: "i",
8194
description: "Create a ts-to-zod.config.js file",
@@ -234,19 +247,11 @@ See more help with --help`,
234247

235248
const sourceText = await readFile(inputPath, "utf-8");
236249

237-
const generateOptions: GenerateProps = {
250+
const generateOptions = this.extractGenerateOptions(
238251
sourceText,
239-
...fileConfig,
240-
};
241-
if (typeof flags.maxRun === "number") {
242-
generateOptions.maxRun = flags.maxRun;
243-
}
244-
if (typeof flags.keepComments === "boolean") {
245-
generateOptions.keepComments = flags.keepComments;
246-
}
247-
if (typeof flags.skipParseJSDoc === "boolean") {
248-
generateOptions.skipParseJSDoc = flags.skipParseJSDoc;
249-
}
252+
fileConfig,
253+
flags
254+
);
250255

251256
const {
252257
errors,
@@ -329,6 +334,54 @@ See more help with --help`,
329334
return { success: true };
330335
}
331336

337+
private extractGenerateOptions(
338+
sourceText: string,
339+
givenFileConfig: Config | undefined,
340+
flags: OutputFlags<typeof TsToZod.flags>
341+
) {
342+
const { maybeOptional, maybeNullable, maybeTypeNames, ...fileConfig } =
343+
givenFileConfig || {};
344+
345+
const maybeConfig: MaybeConfig = {
346+
optional: maybeOptional ?? true,
347+
nullable: maybeNullable ?? true,
348+
typeNames: new Set(maybeTypeNames ?? []),
349+
};
350+
if (typeof flags.maybeTypeName === "string" && flags.maybeTypeName) {
351+
maybeConfig.typeNames = new Set([flags.maybeTypeName]);
352+
}
353+
if (
354+
flags.maybeTypeName &&
355+
Array.isArray(flags.maybeTypeName) &&
356+
flags.maybeTypeName.length
357+
) {
358+
maybeConfig.typeNames = new Set(flags.maybeTypeName);
359+
}
360+
if (typeof flags.maybeOptional === "boolean") {
361+
maybeConfig.optional = flags.maybeOptional;
362+
}
363+
if (typeof flags.maybeNullable === "boolean") {
364+
maybeConfig.nullable = flags.maybeNullable;
365+
}
366+
367+
const generateOptions: GenerateProps = {
368+
sourceText,
369+
maybeConfig,
370+
...fileConfig,
371+
};
372+
373+
if (typeof flags.maxRun === "number") {
374+
generateOptions.maxRun = flags.maxRun;
375+
}
376+
if (typeof flags.keepComments === "boolean") {
377+
generateOptions.keepComments = flags.keepComments;
378+
}
379+
if (typeof flags.skipParseJSDoc === "boolean") {
380+
generateOptions.skipParseJSDoc = flags.skipParseJSDoc;
381+
}
382+
return generateOptions;
383+
}
384+
332385
/**
333386
* Load user config from `ts-to-zod.config.js`
334387
*/

src/config.ts

+54
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,18 @@ export type GetSchemaName = (identifier: string) => string;
1818
export type NameFilter = (name: string) => boolean;
1919
export type JSDocTagFilter = (tags: SimplifiedJSDocTag[]) => boolean;
2020

21+
export type MaybeConfig = {
22+
typeNames: Set<string>;
23+
optional: boolean;
24+
nullable: boolean;
25+
};
26+
27+
export const DefaultMaybeConfig: MaybeConfig = {
28+
typeNames: new Set([]),
29+
optional: true,
30+
nullable: true,
31+
};
32+
2133
export type Config = {
2234
/**
2335
* Path of the input file (types source)
@@ -66,6 +78,48 @@ export type Config = {
6678
* @default false
6779
*/
6880
skipParseJSDoc?: boolean;
81+
82+
/**
83+
* If present, it will enable the Maybe special case for each of the given type names.
84+
* They can be names of interfaces or types.
85+
*
86+
* e.g.
87+
* - maybeTypeNames: ["Maybe"]
88+
* - maybeOptional: true
89+
* - maybeNullable: true
90+
*
91+
* ```ts
92+
* // input:
93+
* export type X = { a: string; b: Maybe<string> };
94+
*
95+
* // output:
96+
* const maybe = <T extends z.ZodTypeAny>(schema: T) => {
97+
* return schema.optional().nullable();
98+
* };
99+
*
100+
* export const xSchema = zod.object({
101+
* a: zod.string(),
102+
* b: maybe(zod.string())
103+
* })
104+
* ```
105+
*/
106+
maybeTypeNames?: string[];
107+
108+
/**
109+
* determines if the Maybe special case is optional (can be treated as undefined) or not
110+
*
111+
* @see maybeTypeNames
112+
* @default true
113+
*/
114+
maybeOptional?: boolean;
115+
116+
/**
117+
* determines if the Maybe special case is nullable (can be treated as null) or not
118+
*
119+
* @see maybeTypeNames
120+
* @default true
121+
*/
122+
maybeNullable?: boolean;
69123
};
70124

71125
export type Configs = Array<

src/config.zod.ts

+7
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
// Generated by ts-to-zod
22
import { z } from "zod";
33

4+
export const maybe = <T extends z.ZodTypeAny>(schema: T) => {
5+
return schema.nullable().optional();
6+
};
7+
48
export const simplifiedJSDocTagSchema = z.object({
59
name: z.string(),
610
value: z.string().optional(),
@@ -31,6 +35,9 @@ export const configSchema = z.object({
3135
getSchemaName: getSchemaNameSchema.optional(),
3236
keepComments: z.boolean().optional().default(false),
3337
skipParseJSDoc: z.boolean().optional().default(false),
38+
maybeTypeNames: z.array(z.string()).optional(),
39+
maybeOptional: z.boolean().optional().default(true),
40+
maybeNullable: z.boolean().optional().default(true),
3441
});
3542

3643
export const configsSchema = z.array(

src/core/generate.test.ts

+89
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,95 @@ describe("generate", () => {
7777
});
7878
});
7979

80+
describe("with maybe", () => {
81+
const sourceText = `
82+
export type Name = "superman" | "clark kent" | "kal-l";
83+
84+
export type Maybe<T> = "this is actually ignored";
85+
86+
// Note that the Superman is declared after
87+
export type BadassSuperman = Omit<Superman, "underKryptonite">;
88+
89+
export interface Superman {
90+
name: Name;
91+
age: number;
92+
underKryptonite?: boolean;
93+
/**
94+
* @format email
95+
**/
96+
email: string;
97+
alias: Maybe<string>;
98+
}
99+
100+
const fly = () => console.log("I can fly!");
101+
`;
102+
103+
const { getZodSchemasFile, getIntegrationTestFile, errors } = generate({
104+
sourceText,
105+
maybeConfig: {
106+
optional: false,
107+
nullable: true,
108+
typeNames: new Set(["Maybe"]),
109+
},
110+
});
111+
112+
it("should generate the zod schemas", () => {
113+
expect(getZodSchemasFile("./hero")).toMatchInlineSnapshot(`
114+
"// Generated by ts-to-zod
115+
import { z } from \\"zod\\";
116+
117+
export const maybe = <T extends z.ZodTypeAny>(schema: T) => {
118+
return schema.nullable();
119+
};
120+
121+
export const nameSchema = z.union([z.literal(\\"superman\\"), z.literal(\\"clark kent\\"), z.literal(\\"kal-l\\")]);
122+
123+
export const supermanSchema = z.object({
124+
name: nameSchema,
125+
age: z.number(),
126+
underKryptonite: z.boolean().optional(),
127+
email: z.string().email(),
128+
alias: maybe(z.string())
129+
});
130+
131+
export const badassSupermanSchema = supermanSchema.omit({ \\"underKryptonite\\": true });
132+
"
133+
`);
134+
});
135+
136+
it("should generate the integration tests", () => {
137+
expect(getIntegrationTestFile("./hero", "hero.zod"))
138+
.toMatchInlineSnapshot(`
139+
"// Generated by ts-to-zod
140+
import { z } from \\"zod\\";
141+
142+
import * as spec from \\"./hero\\";
143+
import * as generated from \\"hero.zod\\";
144+
145+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
146+
function expectType<T>(_: T) {
147+
/* noop */
148+
}
149+
150+
export type nameSchemaInferredType = z.infer<typeof generated.nameSchema>;
151+
152+
export type supermanSchemaInferredType = z.infer<typeof generated.supermanSchema>;
153+
154+
export type badassSupermanSchemaInferredType = z.infer<typeof generated.badassSupermanSchema>;
155+
expectType<spec.Name>({} as nameSchemaInferredType)
156+
expectType<nameSchemaInferredType>({} as spec.Name)
157+
expectType<spec.Superman>({} as supermanSchemaInferredType)
158+
expectType<supermanSchemaInferredType>({} as spec.Superman)
159+
expectType<spec.BadassSuperman>({} as badassSupermanSchemaInferredType)
160+
expectType<badassSupermanSchemaInferredType>({} as spec.BadassSuperman)
161+
"
162+
`);
163+
});
164+
it("should not have any errors", () => {
165+
expect(errors.length).toBe(0);
166+
});
167+
});
168+
80169
describe("with enums", () => {
81170
const sourceText = `
82171
export enum Superhero {

0 commit comments

Comments
 (0)