Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Template Literal handling #217

Merged
merged 11 commits into from
Mar 29, 2024
1 change: 1 addition & 0 deletions example/heros.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface Enemy extends Person {
name: string;
powers: EnemyPower[];
inPrison: boolean;
mainPower: `${EnemyPower}`;
}

export type SupermanEnemy = Superman["enemies"][-1];
Expand Down
5 changes: 5 additions & 0 deletions example/heros.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
154 changes: 154 additions & 0 deletions src/core/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
113 changes: 113 additions & 0 deletions src/core/generateZodSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]
Expand Down
37 changes: 37 additions & 0 deletions src/utils/extractLiteralValue.test.ts
Original file line number Diff line number Diff line change
@@ -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("");
});
});
28 changes: 28 additions & 0 deletions src/utils/extractLiteralValue.ts
Original file line number Diff line number Diff line change
@@ -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 "";
}