diff --git a/package.json b/package.json index 810d185c..90ef1281 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "async": "^3.2.0", "case": "^1.6.3", "chokidar": "^3.5.1", + "debug": "^4.3.4", "fs-extra": "^9.1.0", "inquirer": "^8.2.0", "lodash": "^4.17.21", diff --git a/src/config.ts b/src/config.ts index 6c787701..78f62369 100644 --- a/src/config.ts +++ b/src/config.ts @@ -62,6 +62,13 @@ export type Config = { */ skipParseJSDoc?: boolean; + /** + * Generated file header + * + * @default "// Generated by ts-to-zod\nimport { z } from \"zod\";" + */ + headerText?: string; + /** * Path of z.infer<> types file. */ diff --git a/src/config.zod.ts b/src/config.zod.ts index 6910164d..145b85b3 100644 --- a/src/config.zod.ts +++ b/src/config.zod.ts @@ -30,6 +30,7 @@ export const configSchema = z.object({ getSchemaName: getSchemaNameSchema.optional(), keepComments: z.boolean().optional().default(false), skipParseJSDoc: z.boolean().optional().default(false), + headerText: z.string().default("// Generated by ts-to-zod\nimport { z } from \"zod\";"), inferredTypes: z.string().optional(), }); diff --git a/src/core/generate.ts b/src/core/generate.ts index 334a6b3e..773062cf 100644 --- a/src/core/generate.ts +++ b/src/core/generate.ts @@ -48,6 +48,13 @@ export interface GenerateProps { */ skipParseJSDoc?: boolean; + /** + * Generated file header + * + * @default "// Generated by ts-to-zod\nimport { z } from \"zod\";" + */ + headerText?: string; + /** * Path of z.infer<> types file. */ @@ -66,6 +73,8 @@ export function generate({ getSchemaName = (id) => camel(id) + "Schema", keepComments = false, skipParseJSDoc = false, + headerText = `// Generated by ts-to-zod +import { z } from "z";` }: GenerateProps) { // Create a source file and deal with modules const sourceFile = resolveModules(sourceText); @@ -234,8 +243,7 @@ ${Array.from(zodSchemasWithMissingDependencies).join("\n")}` const imports = Array.from(typeImports.values()); const getZodSchemasFile = ( typesImportPath: string - ) => `// Generated by ts-to-zod -import { z } from "zod"; + ) => `${headerText} ${ imports.length ? `import { ${imports.join(", ")} } from "${typesImportPath}";\n` @@ -258,8 +266,7 @@ ${Array.from(statements.values()) const getIntegrationTestFile = ( typesImportPath: string, zodSchemasImportPath: string - ) => `// Generated by ts-to-zod -import { z } from "zod"; + ) => `${headerText} import * as spec from "${typesImportPath}"; import * as generated from "${zodSchemasImportPath}"; @@ -288,8 +295,7 @@ ${testCases.map(print).join("\n")} const getInferredTypes = ( zodSchemasImportPath: string - ) => `// Generated by ts-to-zod -import { z } from "zod"; + ) => `${headerText} import * as generated from "${zodSchemasImportPath}"; diff --git a/src/core/generateZodSchema.ts b/src/core/generateZodSchema.ts index 55f971aa..edf79c19 100644 --- a/src/core/generateZodSchema.ts +++ b/src/core/generateZodSchema.ts @@ -69,15 +69,13 @@ export function generateZodSchemaVariableStatement({ | ts.CallExpression | ts.Identifier | ts.PropertyAccessExpression + | ts.ArrowFunction | undefined; let dependencies: string[] = []; let requiresImport = false; if (ts.isInterfaceDeclaration(node)) { let schemaExtensionClauses: string[] | undefined; - if (node.typeParameters) { - throw new Error("Interface with generics are not supported!"); - } if (node.heritageClauses) { // Looping on heritageClauses browses the "extends" keywords schemaExtensionClauses = node.heritageClauses.reduce( @@ -111,9 +109,6 @@ export function generateZodSchemaVariableStatement({ } if (ts.isTypeAliasDeclaration(node)) { - if (node.typeParameters) { - throw new Error("Type with generics are not supported!"); - } const jsDocTags = skipParseJSDoc ? {} : getJSDocTags(node, sourceFile); schema = buildZodPrimitive({ @@ -133,6 +128,39 @@ export function generateZodSchemaVariableStatement({ requiresImport = true; } + // process generic dependencies + if (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) { + if (schema !== undefined && node.typeParameters) { + const genericTypes = node + .typeParameters.map((p) => `${p.name.escapedText}`) + const genericDependencies = genericTypes.map((p) => getDependencyName(p)) + dependencies = dependencies + .filter((dep) => !genericDependencies.includes(dep)); + schema = f.createArrowFunction( + undefined, + genericTypes.map((dep) => f.createIdentifier(dep)), + genericTypes.map((dep) => f.createParameterDeclaration( + undefined, + undefined, + undefined, + f.createIdentifier(getDependencyName(dep)), + undefined, + f.createTypeReferenceNode( + f.createQualifiedName( + f.createIdentifier(zodImportValue), + f.createIdentifier(`ZodSchema<${dep}>`) + ), + undefined + ), + undefined + )), + undefined, + f.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + schema + ); + } + } + return { dependencies: uniq(dependencies), statement: f.createVariableStatement( @@ -205,7 +233,37 @@ function buildZodProperties({ return properties; } -function buildZodPrimitive({ +// decorate builder to allow for schema appending/overriding +function buildZodPrimitive(opts: { + z: string; + typeNode: ts.TypeNode; + isOptional: boolean; + isNullable?: boolean; + isPartial?: boolean; + isRequired?: boolean; + jsDocTags: JSDocTags; + sourceFile: ts.SourceFile; + dependencies: string[]; + getDependencyName: (identifierName: string) => string; + skipParseJSDoc: boolean; +}) { + const schema = opts.jsDocTags.schema + delete opts.jsDocTags.schema + const generatedSchema = _buildZodPrimitive(opts); + // schema not specified? return generated one + if (!schema) return generatedSchema; + // schema starts with dot? append it + if (schema.startsWith(".")) { + return f.createPropertyAccessExpression(generatedSchema, f.createIdentifier(schema.slice(1))); + } + // otherwise use schema verbatim + return f.createPropertyAccessExpression( + f.createIdentifier(opts.z), + f.createIdentifier(schema) + ); +} + +function _buildZodPrimitive({ z, typeNode, isOptional, @@ -493,6 +551,18 @@ function buildZodPrimitive({ const nodes = typeNode.types.filter(isNotNull); + // string-only enum? issue z.enum + if (typeNode.types.every((i) => + ts.isLiteralTypeNode(i) && i.literal.kind === ts.SyntaxKind.StringLiteral + )) { + return buildZodSchema( + z, + "enum", + [f.createArrayLiteralExpression(nodes.map((i) => (i as any)["literal"]))], + zodProperties + ); + } + // type A = | 'b' is a valid typescript definition // Zod does not allow `z.union(['b']), so we have to return just the value if (nodes.length === 1) { @@ -530,10 +600,16 @@ function buildZodPrimitive({ }); } + // discriminator specified? use discriminatedUnion return buildZodSchema( z, - "union", - [f.createArrayLiteralExpression(values)], + jsDocTags.discriminator !== undefined + ? "discriminatedUnion" + : "union", + jsDocTags.discriminator !== undefined + ? [f.createStringLiteral(jsDocTags.discriminator), + f.createArrayLiteralExpression(values)] + : [f.createArrayLiteralExpression(values)], zodProperties ); } @@ -726,6 +802,45 @@ function buildZodPrimitive({ ); } +/* + // TRPC procedures? how to iterate over interface methods? + if (ts.isFunctionTypeNode(typeNode)) { + let exp = f.createPropertyAccessExpression(f.createIdentifier("t.procedure"), f.createIdentifier("input")); + exp = f.createCallExpression(exp, undefined, typeNode.parameters.map((p) => + buildZodPrimitive({ + z, + typeNode: + p.type || f.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword), + jsDocTags, + sourceFile, + dependencies, + getDependencyName, + isOptional: Boolean(p.questionToken), + skipParseJSDoc, + }) + )); + exp = f.createPropertyAccessExpression(exp, f.createIdentifier("output")); + exp = f.createCallExpression(exp, undefined, [ + buildZodPrimitive({ + z, + typeNode: typeNode.type, + jsDocTags, + sourceFile, + dependencies, + getDependencyName, + isOptional: false, + skipParseJSDoc, + }), + ]); + exp = f.createPropertyAccessExpression(exp, f.createIdentifier("query")); + exp = f.createCallExpression(exp, undefined, [ + // f.createIdentifier(`({ ctx, input }) => { throw new TRPCError({ code: "NOT_FOUND", cause: { ctx, input } }) }`) + f.createIdentifier(`({ ctx, input }) => { }`) + ]); + return exp; + } + */ + if (ts.isIndexedAccessTypeNode(typeNode)) { return buildSchemaReference({ node: typeNode, diff --git a/src/core/jsDocTags.ts b/src/core/jsDocTags.ts index 027264ce..7b265987 100644 --- a/src/core/jsDocTags.ts +++ b/src/core/jsDocTags.ts @@ -30,6 +30,8 @@ export interface JSDocTags { maxLength?: TagWithError; format?: TagWithError; pattern?: string; + discriminator?: string; + schema?: string; } /** @@ -46,6 +48,8 @@ function isJSDocTagKey(tagName: string): tagName is keyof JSDocTags { "maxLength", "format", "pattern", + "discriminator", + "schema", ]; return (keys as string[]).includes(tagName); } @@ -142,6 +146,17 @@ export function getJSDocTags(nodeType: ts.Node, sourceFile: ts.SourceFile) { jsDocTags[tagName] = tag.comment; } break; + case "discriminator": + // TODO: ensure node is a union type + if (tag.comment) { + jsDocTags[tagName] = tag.comment; + } + break; + case "schema": + if (tag.comment) { + jsDocTags[tagName] = tag.comment; + } + break; } }); }); @@ -252,6 +267,7 @@ export function jsDocTagToZodProperties( ? [f.createFalse()] : typeof jsDocTags.default === "number" ? [f.createNumericLiteral(jsDocTags.default)] + // TODO: catch native enum and do not quote it : [f.createStringLiteral(jsDocTags.default)], }); } diff --git a/src/utils/getImportPath.ts b/src/utils/getImportPath.ts index 9733b80b..826c058a 100644 --- a/src/utils/getImportPath.ts +++ b/src/utils/getImportPath.ts @@ -12,5 +12,7 @@ export function getImportPath(from: string, to: string) { const relativePath = slash(relative(from, to).slice(1)); const { dir, name } = parse(relativePath); - return `${dir}/${name}`; + // import from the full path + // TODO: how to apply conditionally? + return `${dir}/${name}.ts`; } diff --git a/yarn.lock b/yarn.lock index b28e2ea9..38144dea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2699,6 +2699,13 @@ debug@^4.2.0: dependencies: ms "2.1.2" +debug@^4.3.4: + version "4.3.4" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== + dependencies: + ms "2.1.2" + decimal.js@^10.2.1: version "10.2.1" resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.2.1.tgz#238ae7b0f0c793d3e3cea410108b35a2c01426a3"