diff --git a/CHANGELOG.md b/CHANGELOG.md index ec3e22dd..f8228f7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ ### v6.0.0 - Supported Node.js versions: `^22.19.0 || ^24.0.0`. +- The `typescript` dependency is now optional and only required for making `Integration`: + - Either import and assign the `typescript` property to its constructor argument; + - Or use the new static async method `create()` to delegate the import; + - This change addresses the potential memory consumption issue. ## Version 5 diff --git a/README.md b/README.md index 6dc6ea4f..ed1ddd1d 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ schemas, it can be exported to frontend side, thus ensuring that the established Install the package and its peer dependencies. ```shell -pnpm add zod-sockets zod socket.io typescript +pnpm add zod-sockets zod socket.io ``` ## Set up config @@ -577,8 +577,9 @@ In order to establish constraints for events on the client side you can generate ```typescript import { Integration } from "zod-sockets"; +import typescript from "typescript"; -const integration = new Integration({ config, actions }); +const integration = new Integration({ typescript, config, actions }); const typescriptCode = integration.print(); // write this to a file ``` diff --git a/example/generate-client.ts b/example/generate-client.ts index 034a7083..3cc6ee7a 100644 --- a/example/generate-client.ts +++ b/example/generate-client.ts @@ -2,9 +2,10 @@ import { writeFile } from "node:fs/promises"; import { Integration } from "zod-sockets"; import { actions } from "./actions"; import { config } from "./config"; +import typescript from "typescript"; await writeFile( "example-client.ts", - new Integration({ config, actions }).print(), + new Integration({ typescript, config, actions }).print(), "utf-8", ); diff --git a/zod-sockets/package.json b/zod-sockets/package.json index 2d68425a..4d1ff1e4 100644 --- a/zod-sockets/package.json +++ b/zod-sockets/package.json @@ -46,6 +46,11 @@ "typescript": "catalog:peer", "zod": "catalog:peer" }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + }, "devDependencies": { "@types/ramda": "^0.31.0", "socket.io": "catalog:dev", diff --git a/zod-sockets/src/__snapshots__/zts.spec.ts.snap b/zod-sockets/src/__snapshots__/zts.spec.ts.snap index fcaeed14..080403a6 100644 --- a/zod-sockets/src/__snapshots__/zts.spec.ts.snap +++ b/zod-sockets/src/__snapshots__/zts.spec.ts.snap @@ -52,7 +52,7 @@ exports[`zod-to-ts > Example > should produce the expected results 1`] = ` ] | bigint)[]; }; }>; - looseRecord: Record<"one" | "two", boolean>; + looseRecord: Record<"one" | "two", boolean> & Record; map: any; set: any; intersection: (string & number) | bigint; @@ -77,6 +77,13 @@ exports[`zod-to-ts > Example > should produce the expected results 1`] = ` catch: number; pipeline: string; readonly: string; + extended: { + hex: string; + hash: string; + }; + codec: string; + slug: string; + xor: string | number; }" `; @@ -277,6 +284,7 @@ exports[`zod-to-ts > z.optional() > Zod 4: should add question mark only to opti required: string; } ] | undefined; + exact?: string; }" `; diff --git a/zod-sockets/src/integration.spec.ts b/zod-sockets/src/integration.spec.ts index 28cec54c..bc129fa2 100644 --- a/zod-sockets/src/integration.spec.ts +++ b/zod-sockets/src/integration.spec.ts @@ -1,3 +1,4 @@ +import ts from "typescript"; import { z } from "zod"; import { ActionsFactory } from "./actions-factory"; import { Config, createSimpleConfig } from "./config"; @@ -16,6 +17,7 @@ describe("Integration", () => { }); const input = z.tuple([feature.array()]); const instance = new Integration({ + typescript: ts, config: sampleConfig, actions: [ new ActionsFactory(sampleConfig).build({ @@ -28,7 +30,7 @@ describe("Integration", () => { expect(instance.print()).toMatchSnapshot(); }); - test("should handle namespaces with emission", () => { + test("should handle namespaces with emission", async () => { const configWithEmission = new Config().addNamespace({ path: "/test", emission: { @@ -38,7 +40,7 @@ describe("Integration", () => { }, }, }); - const instance = new Integration({ + const instance = await Integration.create({ config: configWithEmission, actions: [], }); diff --git a/zod-sockets/src/integration.ts b/zod-sockets/src/integration.ts index f4211ac7..3fad9c26 100644 --- a/zod-sockets/src/integration.ts +++ b/zod-sockets/src/integration.ts @@ -1,4 +1,4 @@ -import ts from "typescript"; +import type ts from "typescript"; import { z } from "zod"; import { AbstractAction } from "./action"; import { makeCleanId } from "./common-helpers"; @@ -6,15 +6,10 @@ import { Config } from "./config"; import { makeEventFnSchema } from "./integration-helpers"; import { Namespaces, normalizeNS } from "./namespace"; import { zodToTs } from "./zts"; -import { - addJsDoc, - makeType, - printNode, - f, - exportModifier, -} from "./typescript-api"; +import { TypescriptAPI } from "./typescript-api"; -interface IntegrationProps { +interface IntegrationParams { + typescript: typeof ts; config: Config; actions: AbstractAction[]; /** @@ -30,18 +25,20 @@ const fallbackNs = "root"; const registryScopes = ["emission", "actions"]; export class Integration { + /** @internal */ + protected readonly api: TypescriptAPI; #program: ts.Node[] = []; #aliases: Record< string, // namespace Map > = {}; #ids = { - path: f.createIdentifier("path"), - socket: f.createIdentifier("Socket"), - socketBase: f.createIdentifier("SocketBase"), - ioClient: f.createStringLiteral("socket.io-client"), - emission: f.createIdentifier(makeCleanId(registryScopes[0])), - actions: f.createIdentifier(makeCleanId(registryScopes[1])), + path: "path", + socket: "Socket", + socketBase: "SocketBase", + ioClient: "socket.io-client", + emission: makeCleanId(registryScopes[0]), + actions: makeCleanId(registryScopes[1]), }; protected registry: Record< string, // namespace @@ -51,48 +48,49 @@ export class Integration { > > = {}; - #makeAlias( - ns: string, - key: object, - produce: () => ts.TypeNode, - ): ts.TypeReferenceNode { + #makeAlias(ns: string, key: object, produce: () => ts.TypeNode): ts.TypeNode { let name = this.#aliases[ns].get(key)?.name?.text; if (!name) { name = `Type${this.#aliases[ns].size + 1}`; - const temp = f.createLiteralTypeNode(f.createNull()); - this.#aliases[ns].set(key, makeType(name, temp)); - this.#aliases[ns].set(key, makeType(name, produce())); + const temp = this.api.makeLiteralType(null); + this.#aliases[ns].set(key, this.api.makeType(name, temp)); + this.#aliases[ns].set(key, this.api.makeType(name, produce())); } - return f.createTypeReferenceNode(name); + return this.api.ensureTypeNode(name); } constructor({ + typescript, config: { namespaces }, actions, maxOverloads = 3, - }: IntegrationProps) { + }: IntegrationParams) { + this.api = new TypescriptAPI(typescript); this.#program.push( - f.createImportDeclaration( + this.api.f.createImportDeclaration( undefined, - f.createImportClause( - true, + this.api.f.createImportClause( + this.api.ts.SyntaxKind.TypeKeyword, undefined, - f.createNamedImports([ - f.createImportSpecifier( + this.api.f.createNamedImports([ + this.api.f.createImportSpecifier( false, - this.#ids.socket, - this.#ids.socketBase, + this.api.f.createIdentifier(this.#ids.socket), + this.api.f.createIdentifier(this.#ids.socketBase), ), ]), ), - this.#ids.ioClient, + this.api.f.createStringLiteral(this.#ids.ioClient), ), ); for (const [ns, { emission }] of Object.entries(namespaces)) { this.#aliases[ns] = new Map(); this.registry[ns] = { emission: [], actions: [] }; - const commons = { makeAlias: this.#makeAlias.bind(this, ns) }; + const commons = { + makeAlias: this.#makeAlias.bind(this, ns), + api: this.api, + }; for (const [event, { schema, ack }] of Object.entries(emission)) { const node = zodToTs(makeEventFnSchema(schema, ack, maxOverloads), { isResponse: true, @@ -115,69 +113,64 @@ export class Integration { for (const ns in this.registry) { const publicName = makeCleanId(ns) || makeCleanId(fallbackNs); - const nsNameNode = f.createVariableStatement( - exportModifier, - f.createVariableDeclarationList( - [ - f.createVariableDeclaration( - this.#ids.path, - undefined, - undefined, - f.createStringLiteral(normalizeNS(ns)), - ), - ], - ts.NodeFlags.Const, - ), + const nsNameNode = this.api.makeConst( + this.#ids.path, + this.api.f.createStringLiteral(normalizeNS(ns)), + { expose: true }, ); - addJsDoc( + this.api.addJsDoc( nsNameNode, `@desc The actual path of the ${publicName} namespace`, ); const interfaces = Object.entries(this.registry[ns]).map( ([scope, events]) => - f.createInterfaceDeclaration( - exportModifier, + this.api.makeInterface( makeCleanId(scope), - undefined, - undefined, events.map(({ event, node }) => - f.createPropertySignature(undefined, event, undefined, node), + this.api.makeInterfaceProp(event, node), ), + { expose: true }, ), ); - const socketNode = f.createTypeAliasDeclaration( - exportModifier, + const socketNode = this.api.makeType( this.#ids.socket, - undefined, - f.createTypeReferenceNode(this.#ids.socketBase, [ - f.createTypeReferenceNode(this.#ids.emission), - f.createTypeReferenceNode(this.#ids.actions), + this.api.ensureTypeNode(this.#ids.socketBase, [ + this.#ids.emission, + this.#ids.actions, ]), + { expose: true }, ); - addJsDoc( + this.api.addJsDoc( socketNode, - `@example const socket: ${publicName}.${this.#ids.socket.text} = io(${publicName}.${this.#ids.path.text})`, + `@example const socket: ${publicName}.${this.#ids.socket} = io(${publicName}.${this.#ids.path})`, ); this.#program.push( - f.createModuleDeclaration( - exportModifier, - f.createIdentifier(publicName), - f.createModuleBlock([ + this.api.f.createModuleDeclaration( + this.api.exportModifier, + this.api.f.createIdentifier(publicName), + this.api.f.createModuleBlock([ nsNameNode, ...this.#aliases[ns].values(), ...interfaces, socketNode, ]), - ts.NodeFlags.Namespace, + this.api.ts.NodeFlags.Namespace, ), ); } } + public static async create(params: Omit) { + return new Integration({ + ...params, + typescript: (await import("typescript"))["default"], + }); + } + public print(printerOptions?: ts.PrinterOptions) { return this.#program - .map((node) => printNode(node, printerOptions)) + .map((node) => this.api.printNode(node, printerOptions)) .join("\n\n"); } } diff --git a/zod-sockets/src/typescript-api.ts b/zod-sockets/src/typescript-api.ts index fc16b819..d42b5ac8 100644 --- a/zod-sockets/src/typescript-api.ts +++ b/zod-sockets/src/typescript-api.ts @@ -1,5 +1,5 @@ import * as R from "ramda"; -import ts from "typescript"; +import type ts from "typescript"; export type Typeable = | ts.TypeNode @@ -7,122 +7,215 @@ export type Typeable = | string | ts.KeywordTypeSyntaxKind; -export const f = ts.factory; - -export const exportModifier = [f.createModifier(ts.SyntaxKind.ExportKeyword)]; - -export const addJsDoc = (node: T, text: string) => - ts.addSyntheticLeadingComment( - node, - ts.SyntaxKind.MultiLineCommentTrivia, - `* ${text} `, - true, - ); - -export const printNode = ( - node: ts.Node, - printerOptions?: ts.PrinterOptions, -) => { - const sourceFile = ts.createSourceFile( - "print.ts", - "", - ts.ScriptTarget.Latest, - false, - ts.ScriptKind.TS, - ); - const printer = ts.createPrinter(printerOptions); - return printer.printNode(ts.EmitHint.Unspecified, node, sourceFile); -}; - -const safePropRegex = /^[A-Za-z_$][A-Za-z0-9_$]*$/; -export const makePropertyIdentifier = (name: string | number) => - typeof name === "string" && safePropRegex.test(name) - ? f.createIdentifier(name) - : literally(name); - -export const ensureTypeNode = ( - subject: Typeable, - args?: Typeable[], // only for string and id -): ts.TypeNode => - typeof subject === "number" - ? f.createKeywordTypeNode(subject) - : typeof subject === "string" || ts.isIdentifier(subject) - ? f.createTypeReferenceNode(subject, args && R.map(ensureTypeNode, args)) - : subject; - -/** ensures distinct union (unique primitives) */ -export const makeUnion = (entries: ts.TypeNode[]) => { - const nodes = new Map(); - for (const entry of entries) - nodes.set(isPrimitive(entry) ? entry.kind : entry, entry); - return f.createUnionTypeNode(Array.from(nodes.values())); -}; - -export const makeInterfaceProp = ( - name: string | number, - value: Typeable, - { - isOptional, - isDeprecated, - comment, - }: { isOptional?: boolean; isDeprecated?: boolean; comment?: string } = {}, -) => { - const propType = ensureTypeNode(value); - const node = f.createPropertySignature( - undefined, - makePropertyIdentifier(name), - isOptional ? f.createToken(ts.SyntaxKind.QuestionToken) : undefined, - isOptional - ? makeUnion([propType, ensureTypeNode(ts.SyntaxKind.UndefinedKeyword)]) - : propType, - ); - const jsdoc = R.reject(R.isNil, [ - isDeprecated ? "@deprecated" : undefined, - comment, - ]); - return jsdoc.length ? addJsDoc(node, jsdoc.join(" ")) : node; -}; - -export const makeType = ( - name: ts.Identifier | string, - value: ts.TypeNode, - { expose, comment }: { expose?: boolean; comment?: string } = {}, -) => { - const node = f.createTypeAliasDeclaration( - expose ? exportModifier : undefined, - name, - undefined, - value, - ); - return comment ? addJsDoc(node, comment) : node; -}; - -/* eslint-disable prettier/prettier -- shorter and works better this way than overrides */ -export const literally = (subj: T) => ( - typeof subj === "number" ? f.createNumericLiteral(subj) - : typeof subj === "bigint" ? f.createBigIntLiteral(subj.toString()) - : typeof subj === "boolean" ? subj ? f.createTrue() : f.createFalse() - : subj === null ? f.createNull() : f.createStringLiteral(subj) +type TypeParams = + | string[] + | Partial>; + +export class TypescriptAPI { + public ts: typeof ts; + public f: typeof ts.factory; + public exportModifier: ts.ModifierToken[]; + #primitives: ts.KeywordTypeSyntaxKind[]; + static #safePropRegex = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + + constructor(typescript: typeof ts) { + this.ts = typescript; + this.f = this.ts.factory; + this.exportModifier = [ + this.f.createModifier(this.ts.SyntaxKind.ExportKeyword), + ]; + this.#primitives = [ + this.ts.SyntaxKind.AnyKeyword, + this.ts.SyntaxKind.BigIntKeyword, + this.ts.SyntaxKind.BooleanKeyword, + this.ts.SyntaxKind.NeverKeyword, + this.ts.SyntaxKind.NumberKeyword, + this.ts.SyntaxKind.ObjectKeyword, + this.ts.SyntaxKind.StringKeyword, + this.ts.SyntaxKind.SymbolKeyword, + this.ts.SyntaxKind.UndefinedKeyword, + this.ts.SyntaxKind.UnknownKeyword, + this.ts.SyntaxKind.VoidKeyword, + ]; + } + + public addJsDoc = (node: T, text: string) => + this.ts.addSyntheticLeadingComment( + node, + this.ts.SyntaxKind.MultiLineCommentTrivia, + `* ${text} `, + true, + ); + + public printNode = (node: ts.Node, printerOptions?: ts.PrinterOptions) => { + const sourceFile = this.ts.createSourceFile( + "print.ts", + "", + this.ts.ScriptTarget.Latest, + false, + this.ts.ScriptKind.TS, + ); + const printer = this.ts.createPrinter(printerOptions); + return printer.printNode(this.ts.EmitHint.Unspecified, node, sourceFile); + }; + + public makeId = (name: string) => this.f.createIdentifier(name); + + public makePropertyIdentifier = (name: string | number) => + typeof name === "string" && TypescriptAPI.#safePropRegex.test(name) + ? this.makeId(name) + : this.literally(name); + + public ensureTypeNode = ( + subject: Typeable, + args?: Typeable[], // only for string and id + ): ts.TypeNode => + typeof subject === "number" + ? this.f.createKeywordTypeNode(subject) + : typeof subject === "string" || this.ts.isIdentifier(subject) + ? this.f.createTypeReferenceNode( + subject, + args && R.map(this.ensureTypeNode, args), + ) + : subject; + + /** + * @internal + * ensures distinct union (unique primitives) + * */ + public makeUnion = (entries: ts.TypeNode[]) => { + const nodes = new Map< + ts.TypeNode | ts.KeywordTypeSyntaxKind, + ts.TypeNode + >(); + for (const entry of entries) + nodes.set(this.isPrimitive(entry) ? entry.kind : entry, entry); + return this.f.createUnionTypeNode(Array.from(nodes.values())); + }; + + public makeInterfaceProp = ( + name: string | number, + value: Typeable, + { + isOptional, + hasUndefined = isOptional, + isDeprecated, + comment, + }: { + isOptional?: boolean; + hasUndefined?: boolean; + isDeprecated?: boolean; + comment?: string; + } = {}, + ) => { + const propType = this.ensureTypeNode(value); + const node = this.f.createPropertySignature( + undefined, + this.makePropertyIdentifier(name), + isOptional + ? this.f.createToken(this.ts.SyntaxKind.QuestionToken) + : undefined, + hasUndefined + ? this.makeUnion([ + propType, + this.ensureTypeNode(this.ts.SyntaxKind.UndefinedKeyword), + ]) + : propType, + ); + const jsdoc = R.reject(R.isNil, [ + isDeprecated ? "@deprecated" : undefined, + comment, + ]); + return jsdoc.length ? this.addJsDoc(node, jsdoc.join(" ")) : node; + }; + + public makeConst = ( + name: string | ts.Identifier | ts.ArrayBindingPattern, + value: ts.Expression, + { type, expose }: { type?: Typeable; expose?: true } = {}, + ) => + this.f.createVariableStatement( + expose && this.exportModifier, + this.f.createVariableDeclarationList( + [ + this.f.createVariableDeclaration( + name, + undefined, + type ? this.ensureTypeNode(type) : undefined, + value, + ), + ], + this.ts.NodeFlags.Const, + ), + ); + + public makeType = ( + name: ts.Identifier | string, + value: ts.TypeNode, + { + expose, + comment, + params, + }: { expose?: boolean; comment?: string; params?: TypeParams } = {}, + ) => { + const node = this.f.createTypeAliasDeclaration( + expose ? this.exportModifier : undefined, + name, + params && this.makeTypeParams(params), + value, + ); + return comment ? this.addJsDoc(node, comment) : node; + }; + + public makeInterface = ( + name: ts.Identifier | string, + props: ts.PropertySignature[], + { expose, comment }: { expose?: boolean; comment?: string } = {}, + ) => { + const node = this.f.createInterfaceDeclaration( + expose ? this.exportModifier : undefined, + name, + undefined, + undefined, + props, + ); + return comment ? this.addJsDoc(node, comment) : node; + }; + + public makeTypeParams = ( + params: + | string[] + | Partial< + Record + >, + ) => + (Array.isArray(params) + ? params.map((name) => R.pair(name, undefined)) + : Object.entries(params) + ).map(([name, val]) => { + const { type, init } = + typeof val === "object" && "init" in val ? val : { type: val }; + return this.f.createTypeParameterDeclaration( + [], + name, + type ? this.ensureTypeNode(type) : undefined, + init ? this.ensureTypeNode(init) : undefined, + ); + }); + + /* eslint-disable prettier/prettier -- shorter and works better this way than overrides */ + public literally = (subj: T) => ( + typeof subj === "number" ? this.f.createNumericLiteral(subj) + : typeof subj === "bigint" ? this.f.createBigIntLiteral(subj.toString()) + : typeof subj === "boolean" ? subj ? this.f.createTrue() : this.f.createFalse() + : subj === null ? this.f.createNull() : this.f.createStringLiteral(subj) ) as T extends string ? ts.StringLiteral : T extends number ? ts.NumericLiteral - : T extends boolean ? ts.BooleanLiteral : ts.NullLiteral; -/* eslint-enable prettier/prettier */ - -export const makeLiteralType = (subj: Parameters[0]) => - f.createLiteralTypeNode(literally(subj)); - -const primitives: ts.KeywordTypeSyntaxKind[] = [ - ts.SyntaxKind.AnyKeyword, - ts.SyntaxKind.BigIntKeyword, - ts.SyntaxKind.BooleanKeyword, - ts.SyntaxKind.NeverKeyword, - ts.SyntaxKind.NumberKeyword, - ts.SyntaxKind.ObjectKeyword, - ts.SyntaxKind.StringKeyword, - ts.SyntaxKind.SymbolKeyword, - ts.SyntaxKind.UndefinedKeyword, - ts.SyntaxKind.UnknownKeyword, - ts.SyntaxKind.VoidKeyword, -]; - -const isPrimitive = (node: ts.TypeNode): node is ts.KeywordTypeNode => - (primitives as ts.SyntaxKind[]).includes(node.kind); + : T extends boolean ? ts.BooleanLiteral : ts.NullLiteral; + /* eslint-enable prettier/prettier */ + + public makeLiteralType = (subj: Parameters[0]) => + this.f.createLiteralTypeNode(this.literally(subj)); + + public isPrimitive = (node: ts.TypeNode): node is ts.KeywordTypeNode => + (this.#primitives as ts.SyntaxKind[]).includes(node.kind); +} diff --git a/zod-sockets/src/zts-helpers.ts b/zod-sockets/src/zts-helpers.ts index f442fbed..f5c2a4d0 100644 --- a/zod-sockets/src/zts-helpers.ts +++ b/zod-sockets/src/zts-helpers.ts @@ -1,10 +1,13 @@ -import ts from "typescript"; +import type ts from "typescript"; import { FlatObject } from "./common-helpers"; import { SchemaHandler } from "./schema-walker"; +import { TypescriptAPI } from "./typescript-api"; export interface ZTSContext extends FlatObject { isResponse: boolean; makeAlias: (key: object, produce: () => ts.TypeNode) => ts.TypeNode; + /** @internal */ + api: TypescriptAPI; } export type Producer = SchemaHandler; diff --git a/zod-sockets/src/zts.spec.ts b/zod-sockets/src/zts.spec.ts index 5b27c99a..09052b22 100644 --- a/zod-sockets/src/zts.spec.ts +++ b/zod-sockets/src/zts.spec.ts @@ -1,16 +1,18 @@ import assert from "node:assert/strict"; import ts from "typescript"; import { z } from "zod"; -import { f, printNode } from "./typescript-api"; +import { TypescriptAPI } from "./typescript-api"; import { zodToTs } from "./zts"; import { ZTSContext } from "./zts-helpers"; describe("zod-to-ts", () => { + const api = new TypescriptAPI(ts); const printNodeTest = (node: ts.Node) => - printNode(node, { newLine: ts.NewLineKind.LineFeed }); + api.printNode(node, { newLine: ts.NewLineKind.LineFeed }); const ctx: ZTSContext = { isResponse: false, - makeAlias: vi.fn(() => f.createTypeReferenceNode("SomeType")), + makeAlias: vi.fn(() => api.f.createTypeReferenceNode("SomeType")), + api, }; describe("z.array()", () => { @@ -163,11 +165,18 @@ describe("zod-to-ts", () => { catch: z.number().catch(123), pipeline: z.string().regex(/\d+/).transform(Number).pipe(z.number()), readonly: z.string().readonly(), + extended: z.object({}).extend({ hex: z.hex(), hash: z.hash("sha256") }), + codec: z.codec(z.string(), z.number(), { + encode: String, + decode: Number, + }), + slug: z.string().slugify(), + xor: z.xor([z.string(), z.number()]), }); test("should produce the expected results", () => { const node = zodToTs(example, ctx); - expect(printNode(node)).toMatchSnapshot(); + expect(api.printNode(node)).toMatchSnapshot(); }); }); @@ -211,6 +220,7 @@ describe("zod-to-ts", () => { }), ]) .optional(), + exact: z.string().exactOptional(), }); test("Zod 4: does not add undefined to it, unwrap as is", () => { diff --git a/zod-sockets/src/zts.ts b/zod-sockets/src/zts.ts index 8d0f944e..9bdbd304 100644 --- a/zod-sockets/src/zts.ts +++ b/zod-sockets/src/zts.ts @@ -1,4 +1,4 @@ -import ts from "typescript"; +import type ts from "typescript"; import { globalRegistry, z } from "zod"; import { lcFirst, @@ -10,24 +10,7 @@ import { hasCycle } from "./integration-helpers"; import { FirstPartyKind, HandlingRules, walkSchema } from "./schema-walker"; import * as R from "ramda"; import { Producer, ZTSContext } from "./zts-helpers"; -import { - ensureTypeNode, - makeInterfaceProp, - makeLiteralType, - makeUnion, -} from "./typescript-api"; - -const { factory: f } = ts; - -const samples = { - [ts.SyntaxKind.AnyKeyword]: "", - [ts.SyntaxKind.BigIntKeyword]: BigInt(0), - [ts.SyntaxKind.BooleanKeyword]: false, - [ts.SyntaxKind.NumberKeyword]: 0, - [ts.SyntaxKind.ObjectKeyword]: {}, - [ts.SyntaxKind.StringKeyword]: "", - [ts.SyntaxKind.UndefinedKeyword]: undefined, -} satisfies Partial>; +import { TypescriptAPI } from "./typescript-api"; const nodePath = { name: R.path([ @@ -41,18 +24,21 @@ const nodePath = { optional: R.path(["questionToken" satisfies keyof ts.TypeElement]), }; -const onLiteral: Producer = ({ _zod: { def } }: z.core.$ZodLiteral) => { +const onLiteral: Producer = ( + { _zod: { def } }: z.core.$ZodLiteral, + { api }, +) => { const values = def.values.map((entry) => entry === undefined - ? ensureTypeNode(ts.SyntaxKind.UndefinedKeyword) - : makeLiteralType(entry), + ? api.ensureTypeNode(api.ts.SyntaxKind.UndefinedKeyword) + : api.makeLiteralType(entry), ); - return values.length === 1 ? values[0] : makeUnion(values); + return values.length === 1 ? values[0] : api.makeUnion(values); }; const onTemplateLiteral: Producer = ( { _zod: { def } }: z.core.$ZodTemplateLiteral, - { next }, + { next, api }, ) => { const parts = [...def.parts]; const shiftText = () => { @@ -67,78 +53,95 @@ const onTemplateLiteral: Producer = ( } return text; }; - const head = f.createTemplateHead(shiftText()); + const head = api.f.createTemplateHead(shiftText()); const spans: ts.TemplateLiteralTypeSpan[] = []; while (parts.length) { const schema = next(parts.shift() as z.core.$ZodType); const text = shiftText(); const textWrapper = parts.length - ? f.createTemplateMiddle - : f.createTemplateTail; - spans.push(f.createTemplateLiteralTypeSpan(schema, textWrapper(text))); + ? api.f.createTemplateMiddle + : api.f.createTemplateTail; + spans.push(api.f.createTemplateLiteralTypeSpan(schema, textWrapper(text))); } - if (!spans.length) return makeLiteralType(head.text); - return f.createTemplateLiteralType(head, spans); + if (!spans.length) return api.makeLiteralType(head.text); + return api.f.createTemplateLiteralType(head, spans); }; const onObject: Producer = ( obj: z.core.$ZodObject, - { isResponse, next, makeAlias }, + { isResponse, next, makeAlias, api }, ) => { const produce = () => { const members = Object.entries(obj._zod.def.shape).map( ([key, value]) => { const { description: comment, deprecated: isDeprecated } = globalRegistry.get(value) || {}; - return makeInterfaceProp(key, next(value), { + const isOptional = + (isResponse ? value._zod.optout : value._zod.optin) === "optional"; + const hasUndefined = + isOptional && !(value instanceof z.core.$ZodExactOptional); + return api.makeInterfaceProp(key, next(value), { comment, isDeprecated, - isOptional: - (isResponse ? value._zod.optout : value._zod.optin) === "optional", + isOptional, + hasUndefined, }); }, ); - return f.createTypeLiteralNode(members); + return api.f.createTypeLiteralNode(members); }; return hasCycle(obj, { io: isResponse ? "output" : "input" }) ? makeAlias(obj, produce) : produce(); }; -const onArray: Producer = ({ _zod: { def } }: z.core.$ZodArray, { next }) => - f.createArrayTypeNode(next(def.element)); +const onArray: Producer = ( + { _zod: { def } }: z.core.$ZodArray, + { next, api }, +) => api.f.createArrayTypeNode(next(def.element)); -const onEnum: Producer = ({ _zod: { def } }: z.core.$ZodEnum) => - makeUnion(Object.values(def.entries).map(makeLiteralType)); +const onEnum: Producer = ({ _zod: { def } }: z.core.$ZodEnum, { api }) => + api.makeUnion(R.map(api.makeLiteralType, Object.values(def.entries))); const onSomeUnion: Producer = ( { _zod: { def } }: z.core.$ZodUnion | z.core.$ZodDiscriminatedUnion, - { next }, -) => { - return makeUnion(def.options.map(next)); -}; - -const makeSample = (produced: ts.TypeNode) => - samples?.[produced.kind as keyof typeof samples]; + { next, api }, +) => api.makeUnion(def.options.map(next)); const onNullable: Producer = ( { _zod: { def } }: z.core.$ZodNullable, - { next }, -) => makeUnion([next(def.innerType), makeLiteralType(null)]); + { next, api }, +) => api.makeUnion([next(def.innerType), api.makeLiteralType(null)]); -const onTuple: Producer = ({ _zod: { def } }: z.core.$ZodTuple, { next }) => - f.createTupleTypeNode( +const onTuple: Producer = ( + { _zod: { def } }: z.core.$ZodTuple, + { next, api }, +) => + api.f.createTupleTypeNode( def.items .map(next) - .concat(def.rest === null ? [] : f.createRestTypeNode(next(def.rest))), + .concat( + def.rest === null ? [] : api.f.createRestTypeNode(next(def.rest)), + ), ); -const onRecord: Producer = ({ _zod: { def } }: z.core.$ZodRecord, { next }) => - ensureTypeNode("Record", [def.keyType, def.valueType].map(next)); +const onRecord: Producer = ( + { _zod: { def } }: z.core.$ZodRecord, + { next, api }, +) => { + const [keyNode, valueNode] = [def.keyType, def.valueType].map(next); + const primary = api.ensureTypeNode("Record", [keyNode, valueNode]); + const isLoose = def.mode === "loose"; + if (!isLoose) return primary; + return api.f.createIntersectionTypeNode([ + primary, + api.ensureTypeNode("Record", ["PropertyKey", valueNode]), + ]); +}; const intersect = R.tryCatch( - (nodes: ts.TypeNode[]) => { - if (!nodes.every(ts.isTypeLiteralNode)) throw new Error("Not objects"); + (api: TypescriptAPI, nodes: ts.TypeNode[]) => { + if (!nodes.every(api.ts.isTypeLiteralNode)) throw new Error("Not objects"); const members = R.chain(R.prop("members"), nodes); const uniqs = R.uniqWith((...props) => { if (!R.eqBy(nodePath.name, ...props)) return false; @@ -146,20 +149,31 @@ const intersect = R.tryCatch( return true; throw new Error("Has conflicting prop"); }, members); - return f.createTypeLiteralNode(uniqs); + return api.f.createTypeLiteralNode(uniqs); }, - (_err, nodes) => f.createIntersectionTypeNode(nodes), + (_err, api, nodes) => api.f.createIntersectionTypeNode(nodes), ); const onIntersection: Producer = ( { _zod: { def } }: z.core.$ZodIntersection, - { next }, -) => intersect([def.left, def.right].map(next)); + { next, api }, +) => intersect(api, [def.left, def.right].map(next)); const onPrimitive = - (syntaxKind: ts.KeywordTypeSyntaxKind): Producer => - () => - ensureTypeNode(syntaxKind); + ( + syntaxKind: + | "AnyKeyword" + | "BigIntKeyword" + | "BooleanKeyword" + | "NeverKeyword" + | "NumberKeyword" + | "StringKeyword" + | "UndefinedKeyword" + | "UnknownKeyword" + | "VoidKeyword", + ): Producer => + ({}, { api }) => + api.ensureTypeNode(api.ts.SyntaxKind[syntaxKind]); const onWrapped: Producer = ( { @@ -169,61 +183,72 @@ const onWrapped: Producer = ( | z.core.$ZodCatch | z.core.$ZodDefault | z.core.$ZodOptional - | z.core.$ZodNonOptional, + | z.core.$ZodNonOptional + | z.core.$ZodExactOptional, { next }, ) => next(def.innerType); -const getFallback = (isResponse: boolean) => - ensureTypeNode( - isResponse ? ts.SyntaxKind.UnknownKeyword : ts.SyntaxKind.AnyKeyword, +const getFallback = (api: TypescriptAPI, isResponse: boolean) => + api.ensureTypeNode( + isResponse + ? api.ts.SyntaxKind.UnknownKeyword + : api.ts.SyntaxKind.AnyKeyword, ); const onPipeline: Producer = ( { _zod: { def } }: z.core.$ZodPipe, - { next, isResponse }, + { next, isResponse, api }, ) => { const target = def[isResponse ? "out" : "in"]; const opposite = def[isResponse ? "in" : "out"]; if (!isSchema(target, "transform")) return next(target); const opposingType = next(opposite); + const samples = { + [api.ts.SyntaxKind.AnyKeyword]: "", + [api.ts.SyntaxKind.BigIntKeyword]: BigInt(0), + [api.ts.SyntaxKind.BooleanKeyword]: false, + [api.ts.SyntaxKind.NumberKeyword]: 0, + [api.ts.SyntaxKind.ObjectKeyword]: {}, + [api.ts.SyntaxKind.StringKeyword]: "", + [api.ts.SyntaxKind.UndefinedKeyword]: undefined, + } satisfies Partial>; + const sample = samples[opposingType.kind as keyof typeof samples]; const targetType = getTransformedType( target, - isSchema(opposite, "date") - ? new Date() - : makeSample(opposingType), + isSchema(opposite, "date") ? new Date() : sample, ); const resolutions: Partial< Record, ts.KeywordTypeSyntaxKind> > = { - number: ts.SyntaxKind.NumberKeyword, - bigint: ts.SyntaxKind.BigIntKeyword, - boolean: ts.SyntaxKind.BooleanKeyword, - string: ts.SyntaxKind.StringKeyword, - undefined: ts.SyntaxKind.UndefinedKeyword, - object: ts.SyntaxKind.ObjectKeyword, + number: api.ts.SyntaxKind.NumberKeyword, + bigint: api.ts.SyntaxKind.BigIntKeyword, + boolean: api.ts.SyntaxKind.BooleanKeyword, + string: api.ts.SyntaxKind.StringKeyword, + undefined: api.ts.SyntaxKind.UndefinedKeyword, + object: api.ts.SyntaxKind.ObjectKeyword, }; - return ensureTypeNode( - (targetType && resolutions[targetType]) || getFallback(isResponse), + return api.ensureTypeNode( + (targetType && resolutions[targetType]) || getFallback(api, isResponse), ); }; -const onNull: Producer = () => makeLiteralType(null); +const onNull: Producer = ({}, { api }) => api.makeLiteralType(null); const onLazy: Producer = ( { _zod: { def } }: z.core.$ZodLazy, { makeAlias, next }, ) => makeAlias(def.getter, () => next(def.getter())); -const onFunction: Producer = (schema: z.core.$ZodFunction, { next }) => { +const onFunction: Producer = (schema: z.core.$ZodFunction, { next, api }) => { const { input, output } = schema._zod.def; if (!isSchema(input, "tuple")) throw new Error("z.function()::input must be a tuple"); const params = input._zod.def.items.map((subject, index) => { const { description } = globalRegistry.get(subject) || {}; - return f.createParameterDeclaration( + return api.f.createParameterDeclaration( undefined, undefined, - f.createIdentifier( + api.f.createIdentifier( description ? lcFirst(makeCleanId(description)) : `${isSchema(subject, "function") ? "cb" : "p"}${index + 1}`, @@ -236,10 +261,10 @@ const onFunction: Producer = (schema: z.core.$ZodFunction, { next }) => { if (rest) { const { description } = globalRegistry.get(rest) || {}; params.push( - f.createParameterDeclaration( + api.f.createParameterDeclaration( undefined, - f.createToken(ts.SyntaxKind.DotDotDotToken), - f.createIdentifier( + api.f.createToken(api.ts.SyntaxKind.DotDotDotToken), + api.f.createIdentifier( description ? lcFirst(makeCleanId(description)) : "rest", ), undefined, @@ -247,19 +272,19 @@ const onFunction: Producer = (schema: z.core.$ZodFunction, { next }) => { ), ); } - return f.createFunctionTypeNode(undefined, params, next(output)); + return api.f.createFunctionTypeNode(undefined, params, next(output)); }; const producers: HandlingRules = { - string: onPrimitive(ts.SyntaxKind.StringKeyword), - number: onPrimitive(ts.SyntaxKind.NumberKeyword), - bigint: onPrimitive(ts.SyntaxKind.BigIntKeyword), - boolean: onPrimitive(ts.SyntaxKind.BooleanKeyword), - any: onPrimitive(ts.SyntaxKind.AnyKeyword), - undefined: onPrimitive(ts.SyntaxKind.UndefinedKeyword), - never: onPrimitive(ts.SyntaxKind.NeverKeyword), - void: onPrimitive(ts.SyntaxKind.VoidKeyword), - unknown: onPrimitive(ts.SyntaxKind.UnknownKeyword), + string: onPrimitive("StringKeyword"), + number: onPrimitive("NumberKeyword"), + bigint: onPrimitive("BigIntKeyword"), + boolean: onPrimitive("BooleanKeyword"), + any: onPrimitive("AnyKeyword"), + undefined: onPrimitive("UndefinedKeyword"), + never: onPrimitive("NeverKeyword"), + void: onPrimitive("VoidKeyword"), + unknown: onPrimitive("UnknownKeyword"), null: onNull, array: onArray, tuple: onTuple, @@ -284,6 +309,6 @@ const producers: HandlingRules = { export const zodToTs = (schema: z.ZodType, ctx: ZTSContext) => walkSchema(schema, { rules: producers, - onMissing: ({}, { isResponse }) => getFallback(isResponse), + onMissing: ({}, { isResponse, api }) => getFallback(api, isResponse), ctx, });