diff --git a/example/heros.ts b/example/heros.ts index 887a83c..fd78c43 100644 --- a/example/heros.ts +++ b/example/heros.ts @@ -17,6 +17,7 @@ export interface Enemy extends Person { name: string; powers: EnemyPower[]; inPrison: boolean; + mainPower: `${EnemyPower}`; } export type SupermanEnemy = Superman["enemies"][-1]; diff --git a/example/heros.zod.ts b/example/heros.zod.ts index 7495ee2..46aa9f6 100644 --- a/example/heros.zod.ts +++ b/example/heros.zod.ts @@ -14,6 +14,11 @@ export const enemySchema = personSchema.extend({ name: z.string(), powers: z.array(enemyPowerSchema), inPrison: z.boolean(), + mainPower: z.union([ + z.literal("flight"), + z.literal("strength"), + z.literal("speed"), + ]), }); export const supermanSchema = z.object({ diff --git a/src/core/generate.test.ts b/src/core/generate.test.ts index c46043a..eda45f5 100644 --- a/src/core/generate.test.ts +++ b/src/core/generate.test.ts @@ -141,6 +141,160 @@ describe("generate", () => { }); }); + describe("with template literal", () => { + describe("should handle simple reference of one union type", () => { + const sourceText = + 'export type HeroGender = "Man" | "Woman";' + + "export type Heros = `Super${HeroGender}`;"; + + const { getZodSchemasFile, errors } = generate({ + sourceText, + }); + + it("should generate the zod schemas", () => { + expect(getZodSchemasFile("./superhero")).toMatchInlineSnapshot(` + "// Generated by ts-to-zod + import { z } from "zod"; + + export const heroGenderSchema = z.union([z.literal("Man"), z.literal("Woman")]); + + export const herosSchema = z.union([z.literal("SuperMan"), z.literal("SuperWoman")]); + " + `); + }); + + it("should not have any errors", () => { + expect(errors.length).toBe(0); + }); + }); + + describe("should handle combination of 2 Union Types", () => { + const sourceText = + 'export type HeroPrefix = "Super" | "Wonder"' + + 'export type HeroGender = "Man" | "Woman";' + + "export type Heros = `${HeroPrefix}${HeroGender}`;"; + + const { getZodSchemasFile, errors } = generate({ + sourceText, + }); + + it("should generate the zod schemas", () => { + expect(getZodSchemasFile("./superhero")).toMatchInlineSnapshot(` + "// Generated by ts-to-zod + import { z } from "zod"; + + export const heroPrefixSchema = z.union([z.literal("Super"), z.literal("Wonder")]); + + export const heroGenderSchema = z.union([z.literal("Man"), z.literal("Woman")]); + + export const herosSchema = z.union([z.literal("SuperMan"), z.literal("SuperWoman"), z.literal("WonderMan"), z.literal("WonderWoman")]); + " + `); + }); + + it("should not have any errors", () => { + expect(errors.length).toBe(0); + }); + }); + + describe("should handle combination of 2 Union Types with null option", () => { + const sourceText = + 'export type HeroPrefix = "Super" | "Wonder" | null;' + + 'export type HeroGender = "Man" | "Woman";' + + "export type Heros = `${HeroPrefix}${HeroGender}`;"; + + const { getZodSchemasFile, errors } = generate({ + sourceText, + }); + + it("should generate the zod schemas", () => { + expect(getZodSchemasFile("./superhero")).toMatchInlineSnapshot(` + "// Generated by ts-to-zod + import { z } from "zod"; + + export const heroPrefixSchema = z.union([z.literal("Super"), z.literal("Wonder")]).nullable(); + + export const heroGenderSchema = z.union([z.literal("Man"), z.literal("Woman")]); + + export const herosSchema = z.union([z.literal("SuperMan"), z.literal("SuperWoman"), z.literal("WonderMan"), z.literal("WonderWoman")]).nullable(); + " + `); + }); + + console.log(errors); + it("should not have any errors", () => { + expect(errors.length).toBe(0); + }); + }); + + describe("should handle combination of 2 Union Types with interpolated strings", () => { + const sourceText = + 'export type HeroPrefix = "Super" | "Wonder"' + + 'export type HeroGender = "Man" | "Woman";' + + "export type Heros = `$${HeroPrefix}-${HeroGender}*`;"; + + const { getZodSchemasFile, errors } = generate({ + sourceText, + }); + + it("should generate the zod schemas", () => { + expect(getZodSchemasFile("./superhero")).toMatchInlineSnapshot(` + "// Generated by ts-to-zod + import { z } from "zod"; + + export const heroPrefixSchema = z.union([z.literal("Super"), z.literal("Wonder")]); + + export const heroGenderSchema = z.union([z.literal("Man"), z.literal("Woman")]); + + export const herosSchema = z.union([z.literal("$Super-Man*"), z.literal("$Super-Woman*"), z.literal("$Wonder-Man*"), z.literal("$Wonder-Woman*")]); + " + `); + }); + + it("should not have any errors", () => { + expect(errors.length).toBe(0); + }); + }); + + describe("should handle enum in string template", () => { + const sourceText = + `export enum Gender { + Man = 'Man', + Woman = 'Woman', + } + + export interface Hero { + name: ` + + "`Super${Gender}`" + + `; + }' + + `; + + const { getZodSchemasFile, errors } = generate({ + sourceText, + }); + + it("should generate the zod schemas", () => { + expect(getZodSchemasFile("./superhero")).toMatchInlineSnapshot(` + "// Generated by ts-to-zod + import { z } from "zod"; + import { Gender } from "./superhero"; + + export const genderSchema = z.nativeEnum(Gender); + + export const heroSchema = z.object({ + name: z.union([z.literal("SuperMan"), z.literal("SuperWoman")]) + }); + " + `); + }); + + it("should not have any errors", () => { + expect(errors.length).toBe(0); + }); + }); + }); + describe("with circular references", () => { const sourceText = ` export interface Villain { diff --git a/src/core/generateZodSchema.ts b/src/core/generateZodSchema.ts index be4c1c7..039d3e7 100644 --- a/src/core/generateZodSchema.ts +++ b/src/core/generateZodSchema.ts @@ -4,6 +4,8 @@ import ts, { factory as f } from "typescript"; import { CustomJSDocFormatTypes } from "../config"; import { findNode } from "../utils/findNode"; import { isNotNull } from "../utils/isNotNull"; +import { generateCombinations } from "../utils/generateCombinations"; +import { extractLiteralValue } from "../utils/extractLiteralValue"; import { JSDocTags, ZodProperty, @@ -889,6 +891,117 @@ function buildZodPrimitive({ return buildZodSchema(z, "unknown", [], zodProperties); } + if (ts.isTemplateLiteralTypeNode(typeNode)) { + let ignoreNode = false; + + // Handling null outside of the template literal browsing + let hasNull = false; + + // Extracting the values from the template literal + const spanValues: string[][] = []; + spanValues.push([typeNode.head.text]); + + typeNode.templateSpans.forEach((span) => { + if (ts.isTypeReferenceNode(span.type)) { + const targetNode = findNode( + sourceFile, + (n): n is ts.TypeAliasDeclaration | ts.EnumDeclaration => { + return ( + ((ts.isTypeAliasDeclaration(n) && ts.isUnionTypeNode(n.type)) || + ts.isEnumDeclaration(n)) && + n.name.getText(sourceFile) === + (span.type as ts.TypeReferenceNode).typeName.getText(sourceFile) + ); + } + ); + + if (targetNode) { + if ( + ts.isTypeAliasDeclaration(targetNode) && + ts.isUnionTypeNode(targetNode.type) + ) { + hasNull = + hasNull || + Boolean( + targetNode.type.types.find( + (i) => + ts.isLiteralTypeNode(i) && + i.literal.kind === ts.SyntaxKind.NullKeyword + ) + ); + + spanValues.push( + targetNode.type.types + .map((i) => { + if (ts.isLiteralTypeNode(i)) + return extractLiteralValue(i.literal); + return ""; + }) + .filter((i) => i !== "") + ); + } else if (ts.isEnumDeclaration(targetNode)) { + spanValues.push( + targetNode.members + .map((i) => { + if (i.initializer) return extractLiteralValue(i.initializer); + else { + console.warn( + ` » Warning: enum member without initializer '${targetNode.name.getText( + sourceFile + )}.${i.name.getText(sourceFile)}' is not supported.` + ); + ignoreNode = true; + } + return ""; + }) + .filter((i) => i !== "") + ); + } + } else { + console.warn( + ` » Warning: reference not found '${span.type.getText( + sourceFile + )}' in Template Literal.` + ); + ignoreNode = true; + } + spanValues.push([span.literal.text]); + } else { + console.warn( + ` » Warning: node '${span.type.getText( + sourceFile + )}' not supported in Template Literal.` + ); + ignoreNode = true; + } + }); + + // Handling null value outside of the union type + if (hasNull) { + zodProperties.push({ + identifier: "nullable", + }); + } + + if (!ignoreNode) { + return buildZodSchema( + z, + "union", + [ + f.createArrayLiteralExpression( + generateCombinations(spanValues).map((v) => + buildZodSchema(z, "literal", [f.createStringLiteral(v)]) + ) + ), + ], + zodProperties + ); + } else { + console.warn(` » ...falling back into 'z.any()'`); + return buildZodSchema(z, "any", [], zodProperties); + } + } + console.warn( ` » Warning: '${ ts.SyntaxKind[typeNode.kind] diff --git a/src/utils/extractLiteralValue.test.ts b/src/utils/extractLiteralValue.test.ts new file mode 100644 index 0000000..1f7a740 --- /dev/null +++ b/src/utils/extractLiteralValue.test.ts @@ -0,0 +1,37 @@ +import ts, { factory } from "typescript"; +import { extractLiteralValue } from "./extractLiteralValue"; + +describe("extractLiteralValue", () => { + it("should extract string literal value", () => { + const source = factory.createStringLiteral("hello"); + expect(extractLiteralValue(source)).toBe("hello"); + }); + + it("should extract numeric literal value", () => { + const source = factory.createNumericLiteral("42"); + expect(extractLiteralValue(source)).toBe("42"); + }); + + it("should extract negative numeric literal value", () => { + const source = factory.createPrefixUnaryExpression( + ts.SyntaxKind.MinusToken, + factory.createNumericLiteral("42") + ); + expect(extractLiteralValue(source)).toBe("-42"); + }); + + it("should extract true literal value", () => { + const source = factory.createTrue(); + expect(extractLiteralValue(source)).toBe("true"); + }); + + it("should extract false literal value", () => { + const source = factory.createFalse(); + expect(extractLiteralValue(source)).toBe("false"); + }); + + it("should return empty string for unknown literal value", () => { + const source = factory.createNull(); + expect(extractLiteralValue(source)).toBe(""); + }); +}); diff --git a/src/utils/extractLiteralValue.ts b/src/utils/extractLiteralValue.ts new file mode 100644 index 0000000..a2a66f2 --- /dev/null +++ b/src/utils/extractLiteralValue.ts @@ -0,0 +1,28 @@ +import ts from "typescript"; + +/** + * Extract the string representation of a literal value + */ +export function extractLiteralValue(node: ts.Expression): string { + if (ts.isStringLiteral(node)) { + return node.text; + } + if (ts.isNumericLiteral(node)) { + return node.text; + } + if (ts.isPrefixUnaryExpression(node)) { + if ( + node.operator === ts.SyntaxKind.MinusToken && + ts.isNumericLiteral(node.operand) + ) { + return "-" + node.operand.text; + } + } + if (node.kind === ts.SyntaxKind.TrueKeyword) { + return "true"; + } + if (node.kind === ts.SyntaxKind.FalseKeyword) { + return "false"; + } + return ""; +} diff --git a/src/utils/generateCombinations.test.ts b/src/utils/generateCombinations.test.ts new file mode 100644 index 0000000..5d00139 --- /dev/null +++ b/src/utils/generateCombinations.test.ts @@ -0,0 +1,62 @@ +import { generateCombinations } from "./generateCombinations"; + +describe("generateCombinations", () => { + it("should generate all combinations of 1 empty array", () => { + const arrays = [[]]; + const result = generateCombinations(arrays); + expect(result).toEqual([]); + }); + + it("should generate all combinations of 1 array", () => { + const arrays = [["a", "b"]]; + const result = generateCombinations(arrays); + expect(result).toEqual(["a", "b"]); + }); + + it("should generate all combinations of 2 arrays with one empty array", () => { + const arrays = [["a", "b"], []]; + const result = generateCombinations(arrays); + expect(result).toEqual(["a", "b"]); + }); + + it("should generate all combinations of 2 arrays", () => { + const arrays = [ + ["a", "b"], + ["1", "2"], + ]; + const result = generateCombinations(arrays); + expect(result).toEqual(["a1", "a2", "b1", "b2"]); + }); + + it("should generate all combinations of 3 arrays", () => { + const arrays = [ + ["a", "b"], + ["1", "2"], + ["x", "y"], + ]; + const result = generateCombinations(arrays); + expect(result).toEqual([ + "a1x", + "a1y", + "a2x", + "a2y", + "b1x", + "b1y", + "b2x", + "b2y", + ]); + }); + + it("should generate all combinations of 2 arrays with one array of 1", () => { + const arrays = [["a", "b"], ["1"]]; + const result = generateCombinations(arrays); + expect(result).toEqual(["a1", "b1"]); + }); + + it("should generate all combinations of 4 arrays with 2 arrays of 1", () => { + const arrays = [["a", "b"], ["-"], ["x", "y"], ["$"]]; + const result = generateCombinations(arrays); + + expect(result).toEqual(["a-x$", "a-y$", "b-x$", "b-y$"]); + }); +}); diff --git a/src/utils/generateCombinations.ts b/src/utils/generateCombinations.ts new file mode 100644 index 0000000..a0f51e0 --- /dev/null +++ b/src/utils/generateCombinations.ts @@ -0,0 +1,28 @@ +function recursiveGenerateCombinations( + arrays: string[][], + index: number = 0, + current: string[] = [] +): string[][] { + if (index === arrays.length) { + return [current]; + } + + const currentArray = arrays[index].length === 0 ? [""] : arrays[index]; + + const results: string[][] = []; + for (const element of currentArray) { + const combinations = recursiveGenerateCombinations(arrays, index + 1, [ + ...current, + element, + ]); + results.push(...combinations); + } + + return results; +} + +export function generateCombinations(arrays: string[][]): string[] { + return recursiveGenerateCombinations(arrays) + .map((combination) => combination.join("")) + .filter((combination) => combination !== ""); +}