From a07625429601b257dfac776d503cd3b0dd84cc0b Mon Sep 17 00:00:00 2001 From: Thomas V <2619415+tvillaren@users.noreply.github.com> Date: Thu, 20 Jul 2023 21:56:32 +0200 Subject: [PATCH] fix: same behavior as #140 with 3rd party imports --- src/core/validateGeneratedTypes.test.ts | 269 ++++++++++++++++++++++++ src/core/validateGeneratedTypes.ts | 4 +- src/utils/fixOptional.ts | 82 ++++++++ src/utils/fixOptionalAny.ts | 41 ---- 4 files changed, 353 insertions(+), 43 deletions(-) create mode 100644 src/utils/fixOptional.ts delete mode 100644 src/utils/fixOptionalAny.ts diff --git a/src/core/validateGeneratedTypes.test.ts b/src/core/validateGeneratedTypes.test.ts index 04a4d0ab..da22d086 100644 --- a/src/core/validateGeneratedTypes.test.ts +++ b/src/core/validateGeneratedTypes.test.ts @@ -97,6 +97,275 @@ describe("validateGeneratedTypes", () => { expect(errors).toEqual([]); }); + it("should return no error if we use a 'deep' non-optional any", () => { + const sourceTypes = { + sourceText: ` + export interface Citizen { + villain: { + name: string + id: any + }; + }; + `, + relativePath: "source.ts", + }; + + const zodSchemas = { + sourceText: `// Generated by ts-to-zod + import { z } from "zod"; + export const citizenSchema = z.object({ + villain: z.object({ + name: z.string(), + id: z.any() + }) + }); + `, + relativePath: "source.zod.ts", + }; + + const integrationTests = { + sourceText: `// Generated by ts-to-zod + import { z } from "zod"; + + import * as spec from "./${sourceTypes.relativePath.slice(0, -3)}"; + import * as generated from "./${zodSchemas.relativePath.slice(0, -3)}"; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function expectType(_: T) { + /* noop */ + } + + export type CitizenInferredType = z.infer; + + expectType({} as spec.Citizen); + expectType({} as CitizenInferredType); + `, + relativePath: "source.integration.ts", + }; + + const errors = validateGeneratedTypes({ + sourceTypes, + zodSchemas, + integrationTests, + skipParseJSDoc: false, + }); + + expect(errors).toEqual([]); + }); + + it("should return no error if we use a deep external import", () => { + const sourceTypes = { + sourceText: ` + import { Villain } from "villain-module" + + import { Hero } from "hero-module" + + export interface Citizen { + villain: Villain + heroData: { + name: string + hero: Hero + } + }; + `, + relativePath: "source.ts", + }; + + const zodSchemas = { + sourceText: `// Generated by ts-to-zod + import { z } from "zod"; + + import { Villain } from "villain-module"; + + import { Hero } from "hero-module" + + const villainSchema = z.instanceOf(Villain); + + const heroSchema = z.instanceOf(Hero); + + export const citizenSchema = z.object({ + villain: villainSchema + heroData: z.object({ + name: z.string(), + hero: heroSchema + }) + }); + `, + relativePath: "source.zod.ts", + }; + + const integrationTests = { + sourceText: `// Generated by ts-to-zod + import { z } from "zod"; + + import * as spec from "./${sourceTypes.relativePath.slice(0, -3)}"; + import * as generated from "./${zodSchemas.relativePath.slice(0, -3)}"; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function expectType(_: T) { + /* noop */ + } + + export type CitizenInferredType = z.infer; + + expectType({} as spec.Citizen); + expectType({} as CitizenInferredType); + `, + relativePath: "source.integration.ts", + }; + + const errors = validateGeneratedTypes({ + sourceTypes, + zodSchemas, + integrationTests, + skipParseJSDoc: false, + }); + + expect(errors).toEqual([]); + }); + + it("should return no error if we use a deep external import with union", () => { + const sourceTypes = { + sourceText: ` + import { Villain } from "villain-module" + + import { Hero } from "hero-module" + + export interface Citizen { + villain: Villain + heroData: { + name: string + hero: Hero | string + } + }; + `, + relativePath: "source.ts", + }; + + const zodSchemas = { + sourceText: `// Generated by ts-to-zod + import { z } from "zod"; + + import { Villain } from "villain-module"; + + import { Hero } from "hero-module" + + const villainSchema = z.instanceOf(Villain); + + const heroSchema = z.instanceOf(Hero); + + export const citizenSchema = z.object({ + villain: villainSchema, + heroData: z.object({ + name: z.string(), + hero: z.union([heroSchema, z.string()]) + }) + }); + `, + relativePath: "source.zod.ts", + }; + + const integrationTests = { + sourceText: `// Generated by ts-to-zod + import { z } from "zod"; + + import * as spec from "./${sourceTypes.relativePath.slice(0, -3)}"; + import * as generated from "./${zodSchemas.relativePath.slice(0, -3)}"; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function expectType(_: T) { + /* noop */ + } + + export type CitizenInferredType = z.infer; + + expectType({} as spec.Citizen); + expectType({} as CitizenInferredType); + `, + relativePath: "source.integration.ts", + }; + + const errors = validateGeneratedTypes({ + sourceTypes, + zodSchemas, + integrationTests, + skipParseJSDoc: false, + }); + + expect(errors).toEqual([]); + }); + + it("should return no error if we use a deep external import with union", () => { + const sourceTypes = { + sourceText: ` + import { Villain } from "villain-module" + + import { Hero } from "hero-module" + + export interface Citizen { + villain: Villain + heroData: { + name: string + hero: {id:Hero} | string + } + }; + `, + relativePath: "source.ts", + }; + + const zodSchemas = { + sourceText: `// Generated by ts-to-zod + import { z } from "zod"; + + import { Villain } from "villain-module"; + + import { Hero } from "hero-module" + + const villainSchema = z.instanceOf(Villain); + + const heroSchema = z.instanceOf(Hero); + + export const citizenSchema = z.object({ + villain: villainSchema, + heroData: z.object({ + name: z.string(), + hero: z.union([z.object({id: heroSchema}), z.string()]) + }) + }); + `, + relativePath: "source.zod.ts", + }; + + const integrationTests = { + sourceText: `// Generated by ts-to-zod + import { z } from "zod"; + + import * as spec from "./${sourceTypes.relativePath.slice(0, -3)}"; + import * as generated from "./${zodSchemas.relativePath.slice(0, -3)}"; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function expectType(_: T) { + /* noop */ + } + + export type CitizenInferredType = z.infer; + + expectType({} as spec.Citizen); + expectType({} as CitizenInferredType); + `, + relativePath: "source.integration.ts", + }; + + const errors = validateGeneratedTypes({ + sourceTypes, + zodSchemas, + integrationTests, + skipParseJSDoc: false, + }); + + expect(errors).toEqual([]); + }); + it("should return an error if the types doesn't match", () => { const sourceTypes = { sourceText: ` diff --git a/src/core/validateGeneratedTypes.ts b/src/core/validateGeneratedTypes.ts index 8d79b76c..eda09037 100644 --- a/src/core/validateGeneratedTypes.ts +++ b/src/core/validateGeneratedTypes.ts @@ -6,7 +6,7 @@ import { import ts from "typescript"; import { join } from "path"; import { resolveDefaultProperties } from "../utils/resolveDefaultProperties"; -import { fixOptionalAny } from "../utils/fixOptionalAny"; +import { fixOptional } from "../utils/fixOptional"; interface File { sourceText: string; relativePath: string; @@ -38,7 +38,7 @@ export function validateGeneratedTypes({ target: compilerOptions.target, }); const projectRoot = process.cwd(); - const src = fixOptionalAny( + const src = fixOptional( skipParseJSDoc ? sourceTypes.sourceText : resolveDefaultProperties(sourceTypes.sourceText) diff --git a/src/utils/fixOptional.ts b/src/utils/fixOptional.ts new file mode 100644 index 00000000..0540c653 --- /dev/null +++ b/src/utils/fixOptional.ts @@ -0,0 +1,82 @@ +import ts, { factory as f } from "typescript"; +import { getImportIdentifiers } from "./importHandling"; + +/** + * Add optional property to `any` and type references to workaround comparaison issue. + * + * ref: https://github.com/fabien0102/ts-to-zod/issues/140 + */ +export function fixOptional(sourceText: string) { + const sourceFile = ts.createSourceFile( + "index.ts", + sourceText, + ts.ScriptTarget.Latest + ); + const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); + + const importedIdentifiers = getImportedIdentifiers(sourceFile); + + const markAsOptional: ts.TransformerFactory = (context) => { + const visit: ts.Visitor = (node) => { + node = ts.visitEachChild(node, visit, context); + + if (ts.isPropertySignature(node) && node.type) { + if ( + node.type.kind === ts.SyntaxKind.AnyKeyword || + (ts.isTypeReferenceNode(node.type) && + importedIdentifiers.has(node.type.getText(sourceFile))) + ) { + return makePropertyOptional(node); + } else if ( + ts.isArrayTypeNode(node.type) && + ts.isTypeReferenceNode(node.type.elementType) && + importedIdentifiers.has(node.type.elementType.getText(sourceFile)) + ) { + return makePropertyOptional(node); + } else if ( + ts.isIntersectionTypeNode(node.type) || + ts.isUnionTypeNode(node.type) + ) { + const importedType = node.type.types.find( + (child) => + ts.isTypeReferenceNode(child) && + importedIdentifiers.has(child.getText(sourceFile)) + ); + if (importedType) { + return makePropertyOptional(node); + } + } + } + + return node; + }; + + return (node) => ts.visitNode(node, visit); + }; + + const outputFile = ts.transform(sourceFile, [markAsOptional]); + + return printer.printFile(outputFile.transformed[0]); +} + +function getImportedIdentifiers(sourceFile: ts.SourceFile) { + const importNamesAvailable = new Set(); + const typeNameMapBuilder = (node: ts.Node) => { + if (ts.isImportDeclaration(node) && node.importClause) { + const imports = getImportIdentifiers(node); + imports.forEach((i) => importNamesAvailable.add(i)); + } + }; + + ts.forEachChild(sourceFile, typeNameMapBuilder); + return importNamesAvailable; +} + +function makePropertyOptional(node: ts.PropertySignature) { + return ts.factory.createPropertySignature( + node.modifiers, + node.name, + f.createToken(ts.SyntaxKind.QuestionToken), // Add `questionToken` + node.type + ); +} diff --git a/src/utils/fixOptionalAny.ts b/src/utils/fixOptionalAny.ts deleted file mode 100644 index d17c323f..00000000 --- a/src/utils/fixOptionalAny.ts +++ /dev/null @@ -1,41 +0,0 @@ -import ts, { factory as f } from "typescript"; - -/** - * Add optional property to `any` to workaround comparaison issue. - * - * ref: https://github.com/fabien0102/ts-to-zod/issues/140 - */ -export function fixOptionalAny(sourceText: string) { - const sourceFile = ts.createSourceFile( - "index.ts", - sourceText, - ts.ScriptTarget.Latest - ); - const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); - - const markAnyAsOptional: ts.TransformerFactory = (context) => { - const visit: ts.Visitor = (node) => { - node = ts.visitEachChild(node, visit, context); - - if ( - ts.isPropertySignature(node) && - node.type?.kind === ts.SyntaxKind.AnyKeyword - ) { - return ts.factory.createPropertySignature( - node.modifiers, - node.name, - f.createToken(ts.SyntaxKind.QuestionToken), // Add `questionToken` - node.type - ); - } - - return node; - }; - - return (node) => ts.visitNode(node, visit); - }; - - const outputFile = ts.transform(sourceFile, [markAnyAsOptional]); - - return printer.printFile(outputFile.transformed[0]); -}