Skip to content

Commit 96f36d3

Browse files
committed
add some drafts
1 parent 7bbed16 commit 96f36d3

File tree

8 files changed

+171
-16
lines changed

8 files changed

+171
-16
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"async": "^3.2.0",
4646
"case": "^1.6.3",
4747
"chokidar": "^3.5.1",
48+
"debug": "^4.3.4",
4849
"fs-extra": "^9.1.0",
4950
"inquirer": "^8.2.0",
5051
"lodash": "^4.17.21",

src/config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,13 @@ export type Config = {
6262
*/
6363
skipParseJSDoc?: boolean;
6464

65+
/**
66+
* Generated file header
67+
*
68+
* @default "// Generated by ts-to-zod\nimport { z } from \"zod\";"
69+
*/
70+
headerText?: string;
71+
6572
/**
6673
* Path of z.infer<> types file.
6774
*/

src/config.zod.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export const configSchema = z.object({
3030
getSchemaName: getSchemaNameSchema.optional(),
3131
keepComments: z.boolean().optional().default(false),
3232
skipParseJSDoc: z.boolean().optional().default(false),
33+
headerText: z.string().default("// Generated by ts-to-zod\nimport { z } from \"zod\";"),
3334
inferredTypes: z.string().optional(),
3435
});
3536

src/core/generate.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ export interface GenerateProps {
4848
*/
4949
skipParseJSDoc?: boolean;
5050

51+
/**
52+
* Generated file header
53+
*
54+
* @default "// Generated by ts-to-zod\nimport { z } from \"zod\";"
55+
*/
56+
headerText?: string;
57+
5158
/**
5259
* Path of z.infer<> types file.
5360
*/
@@ -66,6 +73,8 @@ export function generate({
6673
getSchemaName = (id) => camel(id) + "Schema",
6774
keepComments = false,
6875
skipParseJSDoc = false,
76+
headerText = `// Generated by ts-to-zod
77+
import { z } from "z";`
6978
}: GenerateProps) {
7079
// Create a source file and deal with modules
7180
const sourceFile = resolveModules(sourceText);
@@ -234,8 +243,7 @@ ${Array.from(zodSchemasWithMissingDependencies).join("\n")}`
234243
const imports = Array.from(typeImports.values());
235244
const getZodSchemasFile = (
236245
typesImportPath: string
237-
) => `// Generated by ts-to-zod
238-
import { z } from "zod";
246+
) => `${headerText}
239247
${
240248
imports.length
241249
? `import { ${imports.join(", ")} } from "${typesImportPath}";\n`
@@ -258,8 +266,7 @@ ${Array.from(statements.values())
258266
const getIntegrationTestFile = (
259267
typesImportPath: string,
260268
zodSchemasImportPath: string
261-
) => `// Generated by ts-to-zod
262-
import { z } from "zod";
269+
) => `${headerText}
263270
264271
import * as spec from "${typesImportPath}";
265272
import * as generated from "${zodSchemasImportPath}";
@@ -288,8 +295,7 @@ ${testCases.map(print).join("\n")}
288295

289296
const getInferredTypes = (
290297
zodSchemasImportPath: string
291-
) => `// Generated by ts-to-zod
292-
import { z } from "zod";
298+
) => `${headerText}
293299
294300
import * as generated from "${zodSchemasImportPath}";
295301

src/core/generateZodSchema.ts

Lines changed: 124 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -69,15 +69,13 @@ export function generateZodSchemaVariableStatement({
6969
| ts.CallExpression
7070
| ts.Identifier
7171
| ts.PropertyAccessExpression
72+
| ts.ArrowFunction
7273
| undefined;
7374
let dependencies: string[] = [];
7475
let requiresImport = false;
7576

7677
if (ts.isInterfaceDeclaration(node)) {
7778
let schemaExtensionClauses: string[] | undefined;
78-
if (node.typeParameters) {
79-
throw new Error("Interface with generics are not supported!");
80-
}
8179
if (node.heritageClauses) {
8280
// Looping on heritageClauses browses the "extends" keywords
8381
schemaExtensionClauses = node.heritageClauses.reduce(
@@ -111,9 +109,6 @@ export function generateZodSchemaVariableStatement({
111109
}
112110

113111
if (ts.isTypeAliasDeclaration(node)) {
114-
if (node.typeParameters) {
115-
throw new Error("Type with generics are not supported!");
116-
}
117112
const jsDocTags = skipParseJSDoc ? {} : getJSDocTags(node, sourceFile);
118113

119114
schema = buildZodPrimitive({
@@ -133,6 +128,39 @@ export function generateZodSchemaVariableStatement({
133128
requiresImport = true;
134129
}
135130

131+
// process generic dependencies
132+
if (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) {
133+
if (schema !== undefined && node.typeParameters) {
134+
const genericTypes = node
135+
.typeParameters.map((p) => `${p.name.escapedText}`)
136+
const genericDependencies = genericTypes.map((p) => getDependencyName(p))
137+
dependencies = dependencies
138+
.filter((dep) => !genericDependencies.includes(dep));
139+
schema = f.createArrowFunction(
140+
undefined,
141+
genericTypes.map((dep) => f.createIdentifier(dep)),
142+
genericTypes.map((dep) => f.createParameterDeclaration(
143+
undefined,
144+
undefined,
145+
undefined,
146+
f.createIdentifier(getDependencyName(dep)),
147+
undefined,
148+
f.createTypeReferenceNode(
149+
f.createQualifiedName(
150+
f.createIdentifier(zodImportValue),
151+
f.createIdentifier(`ZodSchema<${dep}>`)
152+
),
153+
undefined
154+
),
155+
undefined
156+
)),
157+
undefined,
158+
f.createToken(ts.SyntaxKind.EqualsGreaterThanToken),
159+
schema
160+
);
161+
}
162+
}
163+
136164
return {
137165
dependencies: uniq(dependencies),
138166
statement: f.createVariableStatement(
@@ -205,7 +233,37 @@ function buildZodProperties({
205233
return properties;
206234
}
207235

208-
function buildZodPrimitive({
236+
// decorate builder to allow for schema appending/overriding
237+
function buildZodPrimitive(opts: {
238+
z: string;
239+
typeNode: ts.TypeNode;
240+
isOptional: boolean;
241+
isNullable?: boolean;
242+
isPartial?: boolean;
243+
isRequired?: boolean;
244+
jsDocTags: JSDocTags;
245+
sourceFile: ts.SourceFile;
246+
dependencies: string[];
247+
getDependencyName: (identifierName: string) => string;
248+
skipParseJSDoc: boolean;
249+
}) {
250+
const schema = opts.jsDocTags.schema
251+
delete opts.jsDocTags.schema
252+
const generatedSchema = _buildZodPrimitive(opts);
253+
// schema not specified? return generated one
254+
if (!schema) return generatedSchema;
255+
// schema starts with dot? append it
256+
if (schema.startsWith(".")) {
257+
return f.createPropertyAccessExpression(generatedSchema, f.createIdentifier(schema.slice(1)));
258+
}
259+
// otherwise use schema verbatim
260+
return f.createPropertyAccessExpression(
261+
f.createIdentifier(opts.z),
262+
f.createIdentifier(schema)
263+
);
264+
}
265+
266+
function _buildZodPrimitive({
209267
z,
210268
typeNode,
211269
isOptional,
@@ -493,6 +551,18 @@ function buildZodPrimitive({
493551

494552
const nodes = typeNode.types.filter(isNotNull);
495553

554+
// string-only enum? issue z.enum
555+
if (typeNode.types.every((i) =>
556+
ts.isLiteralTypeNode(i) && i.literal.kind === ts.SyntaxKind.StringLiteral
557+
)) {
558+
return buildZodSchema(
559+
z,
560+
"enum",
561+
[f.createArrayLiteralExpression(nodes.map((i) => (i as any)["literal"]))],
562+
zodProperties
563+
);
564+
}
565+
496566
// type A = | 'b' is a valid typescript definition
497567
// Zod does not allow `z.union(['b']), so we have to return just the value
498568
if (nodes.length === 1) {
@@ -530,10 +600,16 @@ function buildZodPrimitive({
530600
});
531601
}
532602

603+
// discriminator specified? use discriminatedUnion
533604
return buildZodSchema(
534605
z,
535-
"union",
536-
[f.createArrayLiteralExpression(values)],
606+
jsDocTags.discriminator !== undefined
607+
? "discriminatedUnion"
608+
: "union",
609+
jsDocTags.discriminator !== undefined
610+
? [f.createStringLiteral(jsDocTags.discriminator),
611+
f.createArrayLiteralExpression(values)]
612+
: [f.createArrayLiteralExpression(values)],
537613
zodProperties
538614
);
539615
}
@@ -726,6 +802,45 @@ function buildZodPrimitive({
726802
);
727803
}
728804

805+
/*
806+
// TRPC procedures? how to iterate over interface methods?
807+
if (ts.isFunctionTypeNode(typeNode)) {
808+
let exp = f.createPropertyAccessExpression(f.createIdentifier("t.procedure"), f.createIdentifier("input"));
809+
exp = f.createCallExpression(exp, undefined, typeNode.parameters.map((p) =>
810+
buildZodPrimitive({
811+
z,
812+
typeNode:
813+
p.type || f.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword),
814+
jsDocTags,
815+
sourceFile,
816+
dependencies,
817+
getDependencyName,
818+
isOptional: Boolean(p.questionToken),
819+
skipParseJSDoc,
820+
})
821+
));
822+
exp = f.createPropertyAccessExpression(exp, f.createIdentifier("output"));
823+
exp = f.createCallExpression(exp, undefined, [
824+
buildZodPrimitive({
825+
z,
826+
typeNode: typeNode.type,
827+
jsDocTags,
828+
sourceFile,
829+
dependencies,
830+
getDependencyName,
831+
isOptional: false,
832+
skipParseJSDoc,
833+
}),
834+
]);
835+
exp = f.createPropertyAccessExpression(exp, f.createIdentifier("query"));
836+
exp = f.createCallExpression(exp, undefined, [
837+
// f.createIdentifier(`({ ctx, input }) => { throw new TRPCError({ code: "NOT_FOUND", cause: { ctx, input } }) }`)
838+
f.createIdentifier(`({ ctx, input }) => { }`)
839+
]);
840+
return exp;
841+
}
842+
*/
843+
729844
if (ts.isIndexedAccessTypeNode(typeNode)) {
730845
return buildSchemaReference({
731846
node: typeNode,

src/core/jsDocTags.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ export interface JSDocTags {
3030
maxLength?: TagWithError<number>;
3131
format?: TagWithError<typeof formats[-1]>;
3232
pattern?: string;
33+
discriminator?: string;
34+
schema?: string;
3335
}
3436

3537
/**
@@ -46,6 +48,8 @@ function isJSDocTagKey(tagName: string): tagName is keyof JSDocTags {
4648
"maxLength",
4749
"format",
4850
"pattern",
51+
"discriminator",
52+
"schema",
4953
];
5054
return (keys as string[]).includes(tagName);
5155
}
@@ -142,6 +146,17 @@ export function getJSDocTags(nodeType: ts.Node, sourceFile: ts.SourceFile) {
142146
jsDocTags[tagName] = tag.comment;
143147
}
144148
break;
149+
case "discriminator":
150+
// TODO: ensure node is a union type
151+
if (tag.comment) {
152+
jsDocTags[tagName] = tag.comment;
153+
}
154+
break;
155+
case "schema":
156+
if (tag.comment) {
157+
jsDocTags[tagName] = tag.comment;
158+
}
159+
break;
145160
}
146161
});
147162
});
@@ -252,6 +267,7 @@ export function jsDocTagToZodProperties(
252267
? [f.createFalse()]
253268
: typeof jsDocTags.default === "number"
254269
? [f.createNumericLiteral(jsDocTags.default)]
270+
// TODO: catch native enum and do not quote it
255271
: [f.createStringLiteral(jsDocTags.default)],
256272
});
257273
}

src/utils/getImportPath.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,7 @@ export function getImportPath(from: string, to: string) {
1212
const relativePath = slash(relative(from, to).slice(1));
1313
const { dir, name } = parse(relativePath);
1414

15-
return `${dir}/${name}`;
15+
// import from the full path
16+
// TODO: how to apply conditionally?
17+
return `${dir}/${name}.ts`;
1618
}

yarn.lock

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2699,6 +2699,13 @@ debug@^4.2.0:
26992699
dependencies:
27002700
ms "2.1.2"
27012701

2702+
debug@^4.3.4:
2703+
version "4.3.4"
2704+
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
2705+
integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
2706+
dependencies:
2707+
ms "2.1.2"
2708+
27022709
decimal.js@^10.2.1:
27032710
version "10.2.1"
27042711
resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.2.1.tgz#238ae7b0f0c793d3e3cea410108b35a2c01426a3"

0 commit comments

Comments
 (0)