Skip to content

Commit

Permalink
feat: Template Literal handling (#217)
Browse files Browse the repository at this point in the history
* feat: add combination helper

* feat: handling Template Literals

* test: add test for #214

* feat: implementing #64

* test: adding template literal as example

* fix: adding fallback for unsupported template literal cases

* refacto: extract extractLiteralValue

* doc: adding warning for unsupported case

* refacto: import ordering

* test: fix use case

* fix: more logical behaviour
  • Loading branch information
tvillaren committed Mar 29, 2024
1 parent 5e736c9 commit e21ea92
Show file tree
Hide file tree
Showing 8 changed files with 428 additions and 0 deletions.
1 change: 1 addition & 0 deletions example/heros.ts
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
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
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
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
@@ -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
@@ -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 "";
}

0 comments on commit e21ea92

Please sign in to comment.