From 65eb6c574d11032aca7e1d36e3e2118e82d6a47e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Turin=CC=83o?= Date: Tue, 26 Jul 2022 12:21:42 +0200 Subject: [PATCH 1/2] support for Maybe special case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 | null | "whatever really"; // this is actually ignored export interface Superman { age: number; aliases: Maybe; } // output export const maybe = (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. --- .eslintrc.js | 2 + .gitignore | 2 + .prettierignore | 10 +++ example/heros.ts | 3 + example/heros.zod.ts | 5 ++ package.json | 3 +- src/cli.ts | 79 +++++++++++++++--- src/config.ts | 54 ++++++++++++ src/config.zod.ts | 7 ++ src/core/generate.test.ts | 89 ++++++++++++++++++++ src/core/generate.ts | 66 +++++++++++++-- src/core/generateZodSchema.test.ts | 13 ++- src/core/generateZodSchema.ts | 127 ++++++++++++++++++++++++++++- src/core/jsDocTags.ts | 40 ++++++--- ts-to-zod.config.js | 1 + 15 files changed, 465 insertions(+), 36 deletions(-) create mode 100644 .prettierignore diff --git a/.eslintrc.js b/.eslintrc.js index a5dc74e7..73e127ee 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -4,6 +4,8 @@ module.exports = { parser: "@typescript-eslint/parser", + ignorePatterns: [".idea/**/*", ".history/**/*"], + parserOptions: { ecmaVersion: 2020, sourceType: "module", diff --git a/.gitignore b/.gitignore index bc9ad100..9676ef95 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ dist build lib .vscode +.idea +.history diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..26460631 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,10 @@ +.DS_Store +node_modules +coverage +.nyc_output +dist +build +lib +.vscode +.idea +.history diff --git a/example/heros.ts b/example/heros.ts index 5cfb90ff..498f3d77 100644 --- a/example/heros.ts +++ b/example/heros.ts @@ -21,12 +21,15 @@ export type SupermanEnemy = Superman["enemies"][-1]; export type SupermanName = Superman["name"]; export type SupermanInvinciblePower = Superman["powers"][2]; +export type Maybe = T | null | undefined; + export interface Superman { name: "superman" | "clark kent" | "kal-l"; enemies: Record; age: number; underKryptonite?: boolean; powers: ["fly", "laser", "invincible"]; + counters?: Maybe; } export interface Villain { diff --git a/example/heros.zod.ts b/example/heros.zod.ts index 076b4acb..c2b3fba3 100644 --- a/example/heros.zod.ts +++ b/example/heros.zod.ts @@ -2,6 +2,10 @@ import { z } from "zod"; import { EnemyPower, Villain } from "./heros"; +export const maybe = (schema: T) => { + return schema.nullable().optional(); +}; + export const enemyPowerSchema = z.nativeEnum(EnemyPower); export const skillsSpeedEnemySchema = z.object({ @@ -28,6 +32,7 @@ export const supermanSchema = z.object({ z.literal("laser"), z.literal("invincible"), ]), + counters: maybe(z.array(enemyPowerSchema)).optional(), }); export const villainSchema: z.ZodSchema = z.lazy(() => diff --git a/package.json b/package.json index 57126ed3..45a681ae 100644 --- a/package.json +++ b/package.json @@ -20,9 +20,10 @@ "scripts": { "build": "tsc -p tsconfig.package.json", "prepublishOnly": "yarn test:ci && rimraf lib && yarn build", - "format": "eslint **/*.{js,jsx,ts,tsx} --fix && prettier **/*.{js,jsx,ts,tsx,json} --write", + "format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix && prettier \"**/*.{js,jsx,ts,tsx,json}\" --write", "test": "jest", "test:ci": "jest --ci --coverage && yarn gen:all && tsc --noEmit", + "type-check": "tsc --noEmit", "gen:all": "./bin/run --all", "gen:example": "./bin/run --config example", "gen:config": "./bin/run --config config", diff --git a/src/cli.ts b/src/cli.ts index 2326096e..4b807c19 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,7 +6,7 @@ import { join, relative, parse } from "path"; import slash from "slash"; import ts from "typescript"; import { generate, GenerateProps } from "./core/generate"; -import { TsToZodConfig, Config } from "./config"; +import { TsToZodConfig, Config, MaybeConfig } from "./config"; import { tsToZodConfigSchema, getSchemaNameSchema, @@ -76,6 +76,19 @@ class TsToZod extends Command { char: "k", description: "Keep parameters comments", }), + maybeOptional: flags.boolean({ + description: + "treat Maybe as optional (can be undefined). Can be combined with maybeNullable", + }), + maybeNullable: flags.boolean({ + description: + "treat Maybe as optional (can be null). Can be combined with maybeOptional", + }), + maybeTypeName: flags.string({ + multiple: true, + description: + "determines the name of the types to treat as 'Maybe'. Can be multiple.", + }), init: flags.boolean({ char: "i", description: "Create a ts-to-zod.config.js file", @@ -234,19 +247,11 @@ See more help with --help`, const sourceText = await readFile(inputPath, "utf-8"); - const generateOptions: GenerateProps = { + const generateOptions = this.extractGenerateOptions( sourceText, - ...fileConfig, - }; - if (typeof flags.maxRun === "number") { - generateOptions.maxRun = flags.maxRun; - } - if (typeof flags.keepComments === "boolean") { - generateOptions.keepComments = flags.keepComments; - } - if (typeof flags.skipParseJSDoc === "boolean") { - generateOptions.skipParseJSDoc = flags.skipParseJSDoc; - } + fileConfig, + flags + ); const { errors, @@ -329,6 +334,54 @@ See more help with --help`, return { success: true }; } + private extractGenerateOptions( + sourceText: string, + givenFileConfig: Config | undefined, + flags: OutputFlags + ) { + const { maybeOptional, maybeNullable, maybeTypeNames, ...fileConfig } = + givenFileConfig || {}; + + const maybeConfig: MaybeConfig = { + optional: maybeOptional ?? true, + nullable: maybeNullable ?? true, + typeNames: new Set(maybeTypeNames ?? []), + }; + if (typeof flags.maybeTypeName === "string" && flags.maybeTypeName) { + maybeConfig.typeNames = new Set([flags.maybeTypeName]); + } + if ( + flags.maybeTypeName && + Array.isArray(flags.maybeTypeName) && + flags.maybeTypeName.length + ) { + maybeConfig.typeNames = new Set(flags.maybeTypeName); + } + if (typeof flags.maybeOptional === "boolean") { + maybeConfig.optional = flags.maybeOptional; + } + if (typeof flags.maybeNullable === "boolean") { + maybeConfig.nullable = flags.maybeNullable; + } + + const generateOptions: GenerateProps = { + sourceText, + maybeConfig, + ...fileConfig, + }; + + if (typeof flags.maxRun === "number") { + generateOptions.maxRun = flags.maxRun; + } + if (typeof flags.keepComments === "boolean") { + generateOptions.keepComments = flags.keepComments; + } + if (typeof flags.skipParseJSDoc === "boolean") { + generateOptions.skipParseJSDoc = flags.skipParseJSDoc; + } + return generateOptions; + } + /** * Load user config from `ts-to-zod.config.js` */ diff --git a/src/config.ts b/src/config.ts index 7d53b342..3645662d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -18,6 +18,18 @@ export type GetSchemaName = (identifier: string) => string; export type NameFilter = (name: string) => boolean; export type JSDocTagFilter = (tags: SimplifiedJSDocTag[]) => boolean; +export type MaybeConfig = { + typeNames: Set; + optional: boolean; + nullable: boolean; +}; + +export const DefaultMaybeConfig: MaybeConfig = { + typeNames: new Set([]), + optional: true, + nullable: true, +}; + export type Config = { /** * Path of the input file (types source) @@ -66,6 +78,48 @@ export type Config = { * @default false */ skipParseJSDoc?: boolean; + + /** + * If present, it will enable the Maybe special case for each of the given type names. + * They can be names of interfaces or types. + * + * e.g. + * - maybeTypeNames: ["Maybe"] + * - maybeOptional: true + * - maybeNullable: true + * + * ```ts + * // input: + * export type X = { a: string; b: Maybe }; + * + * // output: + * const maybe = (schema: T) => { + * return schema.optional().nullable(); + * }; + * + * export const xSchema = zod.object({ + * a: zod.string(), + * b: maybe(zod.string()) + * }) + * ``` + */ + maybeTypeNames?: string[]; + + /** + * determines if the Maybe special case is optional (can be treated as undefined) or not + * + * @see maybeTypeNames + * @default true + */ + maybeOptional?: boolean; + + /** + * determines if the Maybe special case is nullable (can be treated as null) or not + * + * @see maybeTypeNames + * @default true + */ + maybeNullable?: boolean; }; export type Configs = Array< diff --git a/src/config.zod.ts b/src/config.zod.ts index 5c4e0909..4c993978 100644 --- a/src/config.zod.ts +++ b/src/config.zod.ts @@ -1,6 +1,10 @@ // Generated by ts-to-zod import { z } from "zod"; +export const maybe = (schema: T) => { + return schema.nullable().optional(); +}; + export const simplifiedJSDocTagSchema = z.object({ name: z.string(), value: z.string().optional(), @@ -31,6 +35,9 @@ export const configSchema = z.object({ getSchemaName: getSchemaNameSchema.optional(), keepComments: z.boolean().optional().default(false), skipParseJSDoc: z.boolean().optional().default(false), + maybeTypeNames: z.array(z.string()).optional(), + maybeOptional: z.boolean().optional().default(true), + maybeNullable: z.boolean().optional().default(true), }); export const configsSchema = z.array( diff --git a/src/core/generate.test.ts b/src/core/generate.test.ts index d3195d84..396916af 100644 --- a/src/core/generate.test.ts +++ b/src/core/generate.test.ts @@ -77,6 +77,95 @@ describe("generate", () => { }); }); + describe("with maybe", () => { + const sourceText = ` + export type Name = "superman" | "clark kent" | "kal-l"; + + export type Maybe = "this is actually ignored"; + + // Note that the Superman is declared after + export type BadassSuperman = Omit; + + export interface Superman { + name: Name; + age: number; + underKryptonite?: boolean; + /** + * @format email + **/ + email: string; + alias: Maybe; + } + + const fly = () => console.log("I can fly!"); + `; + + const { getZodSchemasFile, getIntegrationTestFile, errors } = generate({ + sourceText, + maybeConfig: { + optional: false, + nullable: true, + typeNames: new Set(["Maybe"]), + }, + }); + + it("should generate the zod schemas", () => { + expect(getZodSchemasFile("./hero")).toMatchInlineSnapshot(` + "// Generated by ts-to-zod + import { z } from \\"zod\\"; + + export const maybe = (schema: T) => { + return schema.nullable(); + }; + + export const nameSchema = z.union([z.literal(\\"superman\\"), z.literal(\\"clark kent\\"), z.literal(\\"kal-l\\")]); + + export const supermanSchema = z.object({ + name: nameSchema, + age: z.number(), + underKryptonite: z.boolean().optional(), + email: z.string().email(), + alias: maybe(z.string()) + }); + + export const badassSupermanSchema = supermanSchema.omit({ \\"underKryptonite\\": true }); + " + `); + }); + + it("should generate the integration tests", () => { + expect(getIntegrationTestFile("./hero", "hero.zod")) + .toMatchInlineSnapshot(` + "// Generated by ts-to-zod + import { z } from \\"zod\\"; + + import * as spec from \\"./hero\\"; + import * as generated from \\"hero.zod\\"; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function expectType(_: T) { + /* noop */ + } + + export type nameSchemaInferredType = z.infer; + + export type supermanSchemaInferredType = z.infer; + + export type badassSupermanSchemaInferredType = z.infer; + expectType({} as nameSchemaInferredType) + expectType({} as spec.Name) + expectType({} as supermanSchemaInferredType) + expectType({} as spec.Superman) + expectType({} as badassSupermanSchemaInferredType) + expectType({} as spec.BadassSuperman) + " + `); + }); + it("should not have any errors", () => { + expect(errors.length).toBe(0); + }); + }); + describe("with enums", () => { const sourceText = ` export enum Superhero { diff --git a/src/core/generate.ts b/src/core/generate.ts index 4147330f..af7ed2e5 100644 --- a/src/core/generate.ts +++ b/src/core/generate.ts @@ -1,7 +1,12 @@ import { camel } from "case"; import { getJsDoc } from "tsutils"; import ts from "typescript"; -import { JSDocTagFilter, NameFilter } from "../config"; +import { + DefaultMaybeConfig, + JSDocTagFilter, + MaybeConfig, + NameFilter, +} from "../config"; import { getSimplifiedJsDocTags } from "../utils/getSimplifiedJsDocTags"; import { resolveModules } from "../utils/resolveModules"; import { generateIntegrationTests } from "./generateIntegrationTests"; @@ -47,8 +52,35 @@ export interface GenerateProps { * @default false */ skipParseJSDoc?: boolean; + + /** + * If present, it will be used to support the `Maybe` special case. + * + * e.g. + * ```ts + * // with maybe config: { typeNames: ["Maybe"], optional: true, nullable: true } + * + * export type X = { a: string; b: Maybe }; + * + * // output: + * const maybe = (schema: T) => { + * return schema.optional().nullable(); + * }; + * + * export const xSchema = zod.object({ + * a: zod.string(), + * b: maybe(zod.string()) + * }) + * ``` + */ + maybeConfig?: MaybeConfig; } +type ValidTSNode = + | ts.InterfaceDeclaration + | ts.TypeAliasDeclaration + | ts.EnumDeclaration; + /** * Generate zod schemas and integration tests from a typescript file. * @@ -56,6 +88,7 @@ export interface GenerateProps { */ export function generate({ sourceText, + maybeConfig = DefaultMaybeConfig, maxRun = 10, nameFilter = () => true, jsDocTagFilter = () => true, @@ -66,10 +99,11 @@ export function generate({ // Create a source file and deal with modules const sourceFile = resolveModules(sourceText); - // Extract the nodes (interface declarations & type aliases) - const nodes: Array< - ts.InterfaceDeclaration | ts.TypeAliasDeclaration | ts.EnumDeclaration - > = []; + const isMaybe = (node: ValidTSNode) => + maybeConfig.typeNames.has(node.name.text); + + // Extract the nodes (interface declarations, type aliases, and enums) + const nodes: Array = []; const visitor = (node: ts.Node) => { if ( @@ -81,6 +115,7 @@ export function generate({ const tags = getSimplifiedJsDocTags(jsDoc); if (!jsDocTagFilter(tags)) return; if (!nameFilter(node.name.text)) return; + if (isMaybe(node)) return; nodes.push(node); } }; @@ -97,6 +132,7 @@ export function generate({ varName, getDependencyName: getSchemaName, skipParseJSDoc, + maybeConfig, }); return { typeName, varName, ...zodSchema }; @@ -178,7 +214,7 @@ ${ imports.length ? `import { ${imports.join(", ")} } from "${typesImportPath}";\n` : "" -} +}${makeMaybePrinted(maybeConfig)} ${Array.from(statements.values()) .map((statement) => print(statement.value)) .join("\n\n")} @@ -263,3 +299,21 @@ ${testCases.map(print).join("\n")} */ const isExported = (i: { typeName: string; value: ts.VariableStatement }) => i.value.modifiers?.find((mod) => mod.kind === ts.SyntaxKind.ExportKeyword); + +const makeMaybePrinted = (maybeConfig: MaybeConfig): string => { + if (!maybeConfig.typeNames.size) return ""; + + let chained = ""; + if (maybeConfig.nullable) { + chained += ".nullable()"; + } + if (maybeConfig.optional) { + chained += ".optional()"; + } + + return ` +export const maybe = (schema: T) => { + return schema${chained}; +}; +`; +}; diff --git a/src/core/generateZodSchema.test.ts b/src/core/generateZodSchema.test.ts index c476bffd..3d89b78a 100644 --- a/src/core/generateZodSchema.test.ts +++ b/src/core/generateZodSchema.test.ts @@ -347,13 +347,15 @@ describe("generateZodSchema", () => { ); }); - it("should generate a complex schema from an interface", () => { + it("should generate a complex schema from an interface (with maybe)", () => { const source = `export interface Superman { name: "superman" | "clark kent" | "kal-l"; enemies: Record; age: number; underKryptonite?: boolean; needGlasses: true | null; + counters?: Maybe; + vulnerableTo: OtherMaybe; };`; expect(generate(source)).toMatchInlineSnapshot(` "export const supermanSchema = z.object({ @@ -361,7 +363,9 @@ describe("generateZodSchema", () => { enemies: z.record(enemySchema), age: z.number(), underKryptonite: z.boolean().optional(), - needGlasses: z.literal(true).nullable() + needGlasses: z.literal(true).nullable(), + counters: maybe(z.array(enemyPowerSchema)).optional(), + vulnerableTo: maybe(enemyPowerSchema) });" `); }); @@ -1044,6 +1048,11 @@ function generate(sourceText: string, z?: string, skipParseJSDoc?: boolean) { sourceFile, varName: zodConstName, skipParseJSDoc, + maybeConfig: { + typeNames: new Set(["Maybe", "OtherMaybe"]), + optional: true, + nullable: true, + }, }); return ts .createPrinter({ newLine: ts.NewLineKind.LineFeed }) diff --git a/src/core/generateZodSchema.ts b/src/core/generateZodSchema.ts index 83dce7e6..fd8c9f59 100644 --- a/src/core/generateZodSchema.ts +++ b/src/core/generateZodSchema.ts @@ -1,6 +1,7 @@ import { camel, lower } from "case"; import uniq from "lodash/uniq"; import * as ts from "typescript"; +import { DefaultMaybeConfig, MaybeConfig } from "../config"; import { findNode } from "../utils/findNode"; import { isNotNull } from "../utils/isNotNull"; import { @@ -8,6 +9,7 @@ import { JSDocTags, jsDocTagToZodProperties, ZodProperty, + zodPropertyIsOptional, } from "./jsDocTags"; const { factory: f } = ts; @@ -48,6 +50,28 @@ export interface GenerateZodSchemaProps { * @default false */ skipParseJSDoc?: boolean; + + /** + * If present, it will be used to support the `Maybe` special case. + * + * e.g. + * ```ts + * // with maybe config: { typeNames: ["Maybe"], optional: true, nullable: true } + * + * export type X = { a: string; b: Maybe }; + * + * // output: + * const maybe = (schema: T) => { + * return schema.optional().nullable(); + * }; + * + * export const xSchema = zod.object({ + * a: zod.string(), + * b: maybe(zod.string()) + * }) + * ``` + */ + maybeConfig?: MaybeConfig; } /** @@ -61,6 +85,7 @@ export function generateZodSchemaVariableStatement({ node, sourceFile, varName, + maybeConfig = DefaultMaybeConfig, zodImportValue = "z", getDependencyName = (identifierName) => camel(`${identifierName}Schema`), skipParseJSDoc = false, @@ -107,6 +132,7 @@ export function generateZodSchemaVariableStatement({ getDependencyName, schemaExtensionClauses, skipParseJSDoc, + maybeConfig, }); } @@ -125,6 +151,7 @@ export function generateZodSchemaVariableStatement({ dependencies, getDependencyName, skipParseJSDoc, + maybeConfig, }); } @@ -160,6 +187,7 @@ function buildZodProperties({ dependencies, getDependencyName, skipParseJSDoc, + maybeConfig, }: { members: ts.NodeArray | ts.PropertySignature[]; zodImportValue: string; @@ -167,6 +195,7 @@ function buildZodProperties({ dependencies: string[]; getDependencyName: (identifierName: string) => string; skipParseJSDoc: boolean; + maybeConfig: MaybeConfig; }) { const properties = new Map< ts.Identifier | ts.StringLiteral, @@ -195,6 +224,7 @@ function buildZodProperties({ dependencies, getDependencyName, skipParseJSDoc, + maybeConfig, }) ); }); @@ -213,6 +243,7 @@ function buildZodPrimitive({ dependencies, getDependencyName, skipParseJSDoc, + maybeConfig, }: { z: string; typeNode: ts.TypeNode; @@ -225,6 +256,7 @@ function buildZodPrimitive({ dependencies: string[]; getDependencyName: (identifierName: string) => string; skipParseJSDoc: boolean; + maybeConfig: MaybeConfig; }): ts.CallExpression | ts.Identifier | ts.PropertyAccessExpression { const zodProperties = jsDocTagToZodProperties( jsDocTags, @@ -244,12 +276,32 @@ function buildZodPrimitive({ dependencies, getDependencyName, skipParseJSDoc, + maybeConfig, }); } if (ts.isTypeReferenceNode(typeNode) && ts.isIdentifier(typeNode.typeName)) { const identifierName = typeNode.typeName.text; + // Deal with `Maybe<>` + if (maybeConfig.typeNames.has(identifierName) && typeNode.typeArguments) { + const innerType = typeNode.typeArguments[0]; + return maybeCall( + buildZodPrimitive({ + z, + typeNode: innerType, + isOptional: false, + jsDocTags: {}, + sourceFile, + dependencies, + getDependencyName, + skipParseJSDoc, + maybeConfig, + }), + { isOptional } + ); + } + // Deal with `Array<>` syntax if (identifierName === "Array" && typeNode.typeArguments) { return buildZodPrimitive({ @@ -261,6 +313,7 @@ function buildZodPrimitive({ dependencies, getDependencyName, skipParseJSDoc, + maybeConfig, }); } @@ -276,6 +329,7 @@ function buildZodPrimitive({ dependencies, getDependencyName, skipParseJSDoc, + maybeConfig, }); } @@ -291,6 +345,7 @@ function buildZodPrimitive({ dependencies, getDependencyName, skipParseJSDoc, + maybeConfig, }); } @@ -305,6 +360,7 @@ function buildZodPrimitive({ dependencies, getDependencyName, skipParseJSDoc, + maybeConfig, }); } @@ -323,6 +379,7 @@ function buildZodPrimitive({ dependencies, getDependencyName, skipParseJSDoc, + maybeConfig, }), ], zodProperties @@ -355,6 +412,7 @@ function buildZodPrimitive({ dependencies, getDependencyName, skipParseJSDoc, + maybeConfig, }), ], zodProperties @@ -381,6 +439,7 @@ function buildZodPrimitive({ dependencies, getDependencyName, skipParseJSDoc, + maybeConfig, }) ), zodProperties @@ -437,6 +496,7 @@ function buildZodPrimitive({ dependencies, getDependencyName, skipParseJSDoc, + maybeConfig, }), f.createIdentifier(lower(identifierName)) ), @@ -477,6 +537,7 @@ function buildZodPrimitive({ dependencies, getDependencyName, skipParseJSDoc, + maybeConfig, }); } @@ -491,6 +552,7 @@ function buildZodPrimitive({ dependencies, getDependencyName, skipParseJSDoc, + maybeConfig, }) ); @@ -520,6 +582,7 @@ function buildZodPrimitive({ dependencies, getDependencyName, skipParseJSDoc, + maybeConfig, }) ); return buildZodSchema( @@ -588,6 +651,7 @@ function buildZodPrimitive({ dependencies, getDependencyName, skipParseJSDoc, + maybeConfig, }), ], zodProperties @@ -603,6 +667,7 @@ function buildZodPrimitive({ dependencies, getDependencyName, skipParseJSDoc, + maybeConfig, }), zodProperties ); @@ -619,6 +684,7 @@ function buildZodPrimitive({ dependencies, getDependencyName, skipParseJSDoc, + maybeConfig, }); return rest.reduce( @@ -639,6 +705,7 @@ function buildZodPrimitive({ dependencies, getDependencyName, skipParseJSDoc, + maybeConfig, }), ] ), @@ -674,6 +741,7 @@ function buildZodPrimitive({ getDependencyName, isOptional: Boolean(p.questionToken), skipParseJSDoc, + maybeConfig, }) ), }, @@ -689,6 +757,7 @@ function buildZodPrimitive({ getDependencyName, isOptional: false, skipParseJSDoc, + maybeConfig, }), ], }, @@ -703,6 +772,7 @@ function buildZodPrimitive({ getDependencyName, sourceFile, dependencies, + maybeConfig, }); } @@ -760,6 +830,22 @@ function buildZodSchema( return withZodProperties(zodCall, properties); } +function maybeCall( + arg: ts.Expression, + opts: { isOptional: boolean } +): ts.CallExpression { + const zodCall = f.createCallExpression( + f.createIdentifier("maybe"), + undefined, + [arg] + ); + const properties = [] as ZodProperty[]; + if (opts.isOptional) { + properties.push(zodPropertyIsOptional()); + } + return withZodProperties(zodCall, properties); +} + function buildZodExtendedSchema( schemaList: string[], args?: ts.Expression[], @@ -824,6 +910,7 @@ function buildZodObject({ getDependencyName, schemaExtensionClauses, skipParseJSDoc, + maybeConfig, }: { typeNode: ts.TypeLiteralNode | ts.InterfaceDeclaration; z: string; @@ -832,6 +919,7 @@ function buildZodObject({ getDependencyName: Required["getDependencyName"]; schemaExtensionClauses?: string[]; skipParseJSDoc: boolean; + maybeConfig: MaybeConfig; }) { const { properties, indexSignature } = typeNode.members.reduce<{ properties: ts.PropertySignature[]; @@ -865,6 +953,7 @@ function buildZodObject({ dependencies, getDependencyName, skipParseJSDoc, + maybeConfig, }); if (schemaExtensionClauses && schemaExtensionClauses.length > 0) { @@ -905,6 +994,7 @@ function buildZodObject({ dependencies, getDependencyName, skipParseJSDoc, + maybeConfig, }), ]); @@ -936,11 +1026,13 @@ function buildSchemaReference( dependencies, sourceFile, getDependencyName, + maybeConfig, }: { node: ts.IndexedAccessTypeNode; dependencies: string[]; sourceFile: ts.SourceFile; getDependencyName: Required["getDependencyName"]; + maybeConfig: MaybeConfig; }, path = "" ): ts.PropertyAccessExpression | ts.Identifier { @@ -975,6 +1067,22 @@ function buildSchemaReference( const member = members.find((m) => m.name?.getText(sourceFile) === key); if (member && ts.isPropertySignature(member) && member.type) { + // Maybe + if ( + ts.isTypeReferenceNode(member.type) && + maybeConfig.typeNames.has(member.type.typeName.getText(sourceFile)) + ) { + return buildSchemaReference( + { + node: node.objectType, + dependencies, + sourceFile, + getDependencyName, + maybeConfig, + }, + `element.${path}` + ); + } // Array if ( ts.isTypeReferenceNode(member.type) && @@ -986,6 +1094,7 @@ function buildSchemaReference( dependencies, sourceFile, getDependencyName, + maybeConfig, }, `element.${path}` ); @@ -998,6 +1107,7 @@ function buildSchemaReference( dependencies, sourceFile, getDependencyName, + maybeConfig, }, `element.${path}` ); @@ -1013,6 +1123,7 @@ function buildSchemaReference( dependencies, sourceFile, getDependencyName, + maybeConfig, }, `valueSchema.${path}` ); @@ -1031,14 +1142,26 @@ function buildSchemaReference( ts.isIndexedAccessTypeNode(node.objectType) ) { return buildSchemaReference( - { node: node.objectType, dependencies, sourceFile, getDependencyName }, + { + node: node.objectType, + dependencies, + sourceFile, + getDependencyName, + maybeConfig, + }, `items[${indexTypeName}].${path}` ); } if (ts.isIndexedAccessTypeNode(node.objectType)) { return buildSchemaReference( - { node: node.objectType, dependencies, sourceFile, getDependencyName }, + { + node: node.objectType, + dependencies, + sourceFile, + getDependencyName, + maybeConfig, + }, `shape.${indexTypeName}.${path}` ); } diff --git a/src/core/jsDocTags.ts b/src/core/jsDocTags.ts index 027264ce..fd1060e0 100644 --- a/src/core/jsDocTags.ts +++ b/src/core/jsDocTags.ts @@ -155,6 +155,30 @@ export type ZodProperty = { expressions?: ts.Expression[]; }; +export function zodPropertyIsOptional() { + return { + identifier: "optional", + }; +} + +export function zodPropertyIsNullable() { + return { + identifier: "nullable", + }; +} + +export function zodPropertyIsPartial() { + return { + identifier: "partial", + }; +} + +export function zodPropertyIsRequired() { + return { + identifier: "required", + }; +} + /** * Convert a set of jsDocTags to zod properties * @@ -223,24 +247,16 @@ export function jsDocTagToZodProperties( }); } if (isOptional) { - zodProperties.push({ - identifier: "optional", - }); + zodProperties.push(zodPropertyIsOptional()); } if (isNullable) { - zodProperties.push({ - identifier: "nullable", - }); + zodProperties.push(zodPropertyIsNullable()); } if (isPartial) { - zodProperties.push({ - identifier: "partial", - }); + zodProperties.push(zodPropertyIsPartial()); } if (isRequired) { - zodProperties.push({ - identifier: "required", - }); + zodProperties.push(zodPropertyIsRequired()); } if (jsDocTags.default !== undefined) { zodProperties.push({ diff --git a/ts-to-zod.config.js b/ts-to-zod.config.js index 00380edb..b3c4aaf9 100644 --- a/ts-to-zod.config.js +++ b/ts-to-zod.config.js @@ -8,6 +8,7 @@ module.exports = [ name: "example", input: "example/heros.ts", output: "example/heros.zod.ts", + maybeTypeNames: ["Maybe"], }, { name: "config", input: "src/config.ts", output: "src/config.zod.ts" }, ]; From eb8a0aba24744a7288e79a7d70a88025cbe4e09a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Turin=CC=83o?= Date: Tue, 26 Jul 2022 12:30:51 +0200 Subject: [PATCH 2/2] typo --- src/cli.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli.ts b/src/cli.ts index 4b807c19..39e72b4c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -82,7 +82,7 @@ class TsToZod extends Command { }), maybeNullable: flags.boolean({ description: - "treat Maybe as optional (can be null). Can be combined with maybeOptional", + "treat Maybe as nullable (can be null). Can be combined with maybeOptional", }), maybeTypeName: flags.string({ multiple: true,