Repository: fabien0102/ts-to-zod
Pull request #217 (2024-03-26 16:48:11)

Template Literal handling
# Why

This PR implement supports for #64 and #214 

### Issue #64

Transforms 
```ts
export enum Foo {
  Bar = 'Bar',
  Baz = 'Baz',
}

export interface Party {
  Time: `${Foo}`;
}
```
into
 
```ts
export const fooSchema = z.nativeEnum(Foo);
export const partySchema = z.object({
  Time: z.union([z.literal('Bar'), z.literal('Baz')]),
});
```

### Issue #214 
Transforms
```ts
export type Prefix = "large" | "high";
export type Subfix = "back" | "front";
export type Joint = `${Prefix}-${Subfix}`;
```
into 
```ts
export const PrefixSchema = z.union([
  z.literal("large"),
  z.literal("high"),
]);

export const SubfixSchema = z.union([
  z.literal("back"),
  z.literal("front"),
]);

export const JointSchema =  z.union([
  z.literal("large-back"),
  z.literal("large-front"),
  z.literal("high-back"),
  z.literal("high-front"),
]);
```


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9457d40..ed3a290 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,12 +1,5 @@
 # Changelog
 
-### [3.7.3](https://www.github.com/fabien0102/ts-to-zod/compare/v3.7.2...v3.7.3) (2024-02-28)
-
-
-### Bug Fixes
-
-* Mark only imported types as optional in validation ([#204](https://www.github.com/fabien0102/ts-to-zod/issues/204)) ([9e52cca](https://www.github.com/fabien0102/ts-to-zod/commit/9e52cca9a60e496c4fee4ef4bf1fb7bc015ef3eb))
-
 ### [3.7.2](https://www.github.com/fabien0102/ts-to-zod/compare/v3.7.1...v3.7.2) (2024-02-23)
 
 
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/package.json b/package.json
index 908ac5e..cf1fa68 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "ts-to-zod",
   "description": "Generate zod schemas from typescript types/interfaces",
-  "version": "3.7.3",
+  "version": "3.7.2",
   "homepage": "https://github.com/fabien0102/ts-to-zod",
   "bugs": "https://github.com/fabien0102/ts-to-zod/issues",
   "repository": "fabien0102/ts-to-zod",
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.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.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 !== "");
+}


Test patch:
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/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/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$"]);
+  });
+});