From 065818cc3d0ba7188912f28ac882d8d86dfc0fbe Mon Sep 17 00:00:00 2001 From: Charles Catta Date: Mon, 17 Jun 2024 13:48:48 -0400 Subject: [PATCH] feat(zui): New typescript transform (#332) --- zui/package.json | 7 +- zui/playground.ts | 106 +++- zui/pnpm-lock.yaml | 38 ++ zui/src/index.ts | 11 + zui/src/setup.test.ts | 49 ++ .../zui-to-typescript-next/index.test.ts | 594 ++++++++++++++++++ .../zui-to-typescript-next/index.ts | 352 +++++++++++ .../zui-to-typescript-next/utils.test.ts | 80 +++ .../zui-to-typescript-next/utils.ts | 46 ++ zui/src/vitest.d.ts | 11 + zui/src/z/types/basetype/index.ts | 22 + zui/src/z/types/defs.ts | 7 + zui/src/z/types/utils/index.ts | 11 +- zui/tsconfig.json | 3 +- zui/vite.config.ts | 2 + 15 files changed, 1320 insertions(+), 19 deletions(-) create mode 100644 zui/src/setup.test.ts create mode 100644 zui/src/transforms/zui-to-typescript-next/index.test.ts create mode 100644 zui/src/transforms/zui-to-typescript-next/index.ts create mode 100644 zui/src/transforms/zui-to-typescript-next/utils.test.ts create mode 100644 zui/src/transforms/zui-to-typescript-next/utils.ts create mode 100644 zui/src/vitest.d.ts diff --git a/zui/package.json b/zui/package.json index e60ef532..408a9446 100644 --- a/zui/package.json +++ b/zui/package.json @@ -1,6 +1,6 @@ { "name": "@bpinternal/zui", - "version": "0.8.4", + "version": "0.8.5", "description": "An extension of Zod for working nicely with UIs and JSON Schemas", "type": "module", "source": "./src/index.ts", @@ -49,14 +49,15 @@ "jsdom": "^24.0.0", "local-ref-resolver": "^0.2.0", "prettier": "^3.0.0", + "react": "^18.2.0", "react-dom": "^18.2.0", "storybook": "^8.0.9", + "ts-morph": "^22.0.0", "tsup": "^8.0.1", "tsx": "^4.7.2", "typescript": "5.4.5", "vite": "^5.2.6", - "vitest": "1.5.2", - "react": "^18.2.0" + "vitest": "1.5.2" }, "dependencies": { "@apidevtools/json-schema-ref-parser": "^11.5.5", diff --git a/zui/playground.ts b/zui/playground.ts index f0ab8ea4..3e5ee08d 100644 --- a/zui/playground.ts +++ b/zui/playground.ts @@ -1,24 +1,47 @@ import { z } from './src' +import fs from 'node:fs' -const aschema = z.object({ - payment: z.discriminatedUnion('type', [ +const obj1 = z + .discriminatedUnion('type', [ z.object({ type: z.literal('Credit Card'), - cardNumber: z.string().title('Credit Card Number').placeholder('1234 5678 9012 3456'), - expirationDate: z.string().title('Expiration Date').placeholder('10/29'), - brand: z.enum(['Visa', 'Mastercard', 'American Express']).default('Visa'), + cardNumber: z + .string() + .title('Credit Card Number') + .placeholder('1234 5678 9012 3456') + .describe('This is the card number'), + expirationDate: z.string().title('Expiration Date').placeholder('10/29').describe('This is the expiration date'), + brand: z + .enum(['Visa', 'Mastercard', 'American Express']) + .nullable() + .optional() + .default('Visa') + .describe('This is the brand of the card'), }), z.object({ type: z.literal('PayPal'), - email: z.string().email().title('Paypal Email').placeholder('john.doe@gmail.com'), + email: z + .string() + .email() + .title('Paypal Email') + .placeholder('john@doe.com') + .describe("This is the paypal account's email address"), }), z.object({ type: z.literal('Bitcoin'), - address: z.string().title('Bitcoin Address').placeholder('1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa'), + address: z + .string() + .title('Bitcoin Address') + .placeholder('1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa') + .describe('This is the bitcoin address'), }), z.object({ type: z.literal('Bank Transfer'), - accountNumber: z.string().title('Account Number').placeholder('1234567890'), + accountNumber: z + .string() + .title('Account Number') + .placeholder('1234567890') + .describe('This is the bank account number'), }), z .object({ @@ -26,15 +49,74 @@ const aschema = z.object({ amount: z .number() .title('Amount') - .disabled((value) => (value || 0) > 100), + .disabled((value) => (value || 0) > 100) + .describe('This is the amount of cash'), }) .disabled((obj) => { return { type: !!obj && obj.amount > 100, } }) - .disabled((obj) => { + .disabled(() => { return false }), - ]), -}) + ]) + .title('payment') + +const obj2 = z + .array( + z.object({ + data: z.templateLiteral().literal('bro ').interpolated(z.string()).literal('!'), + name: z.string().optional().title('Name').placeholder('John Doe').describe('This is the name'), + age: z.number().nullable().title('Age').placeholder('18').describe('This is the age'), + email: z.string().email().title('Email').placeholder('The email').describe('This is the email'), + aUnion: z.union([z.string(), z.number()]).title('A Union').placeholder('A Union').describe('This is a union'), + aTuple: z.tuple([z.string(), z.number()]).title('A Tuple').placeholder('A Tuple').describe('This is a tuple'), + aRecord: z.record(z.number()).title('A Record').placeholder('A Record').describe('This is a record'), + anArray: z.array(z.number()).title('An Array').placeholder('An Array').describe('This is an array'), + aSet: z.set(z.number()).title('A Set').placeholder('A Set').describe('This is a set'), + aMap: z.map(z.string(), z.array(z.any())).title('A Map').placeholder('A Map').describe('This is a map'), + aFunction: z + .function() + .args(z.array(z.union([z.literal('bob'), z.literal('steve')], z.string())).title('names')) + .returns(z.literal('bro')) + .title('A Function') + .placeholder('A Function') + .describe('This is a function'), + aPromise: z.promise(z.number()).title('A Promise').placeholder('A Promise').describe('This is a promise'), + aLazy: z + .lazy(() => z.string()) + .title('A Lazy') + .placeholder('A Lazy') + .describe('This is a lazy'), + aDate: z.date().title('A Date').placeholder('A Date').describe('This is a date'), + aOptional: z.optional(z.string()).title('An Optional').placeholder('An Optional').describe('This is an optional'), + aNullable: z.nullable(z.string()).title('A Nullable').placeholder('A Nullable').describe('This is a nullable'), + }), + ) + .title('users') + +const obj3 = z + .object({ + address: z.lazy(() => + z + .record( + z.number(), + z.object({ + street: z.string(), + number: z.number(), + }), + ) + .describe('This is a record'), + ), + }) + .title('MyObject') + +const typings = [ + obj1.toTypescript({ declaration: true }), + obj2.toTypescript({ declaration: true }), + obj3.toTypescript({ declaration: true }), +].join('\n\n') + + +fs.writeFileSync('./output.ts', typings) diff --git a/zui/pnpm-lock.yaml b/zui/pnpm-lock.yaml index 5e28bc01..8ae9b549 100644 --- a/zui/pnpm-lock.yaml +++ b/zui/pnpm-lock.yaml @@ -96,6 +96,9 @@ importers: storybook: specifier: ^8.0.9 version: 8.0.9(@babel/preset-env@7.24.4(@babel/core@7.24.4))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + ts-morph: + specifier: ^22.0.0 + version: 22.0.0 tsup: specifier: ^8.0.1 version: 8.0.2(@swc/core@1.4.11)(postcss@8.4.38)(typescript@5.4.5) @@ -1622,6 +1625,9 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' + '@ts-morph/common@0.23.0': + resolution: {integrity: sha512-m7Lllj9n/S6sOkCkRftpM7L24uvmfXQFedlW/4hENcuJH1HHm9u5EgxZb9uVjQSCGrbBWBkOGgcTxNg36r6ywA==} + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -2113,6 +2119,9 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + code-block-writer@13.0.1: + resolution: {integrity: sha512-c5or4P6erEA69TxaxTNcHUNcIn+oyxSRTOWV+pSYF+z4epXqNvwvJ70XPGjPNgue83oAFAPBRQYwpAJ/Hpe/Sg==} + color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} @@ -3184,6 +3193,11 @@ packages: engines: {node: '>=10'} hasBin: true + mkdirp@3.0.1: + resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} + engines: {node: '>=10'} + hasBin: true + mlly@1.5.0: resolution: {integrity: sha512-NPVQvAY1xr1QoVeG0cy8yUYC7FQcOx6evl/RjT1wL5FvzPnzOysoqB/jmx/DhssT2dYa8nxECLAaFI/+gVLhDQ==} @@ -3348,6 +3362,9 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-exists@3.0.0: resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} engines: {node: '>=4'} @@ -3980,6 +3997,9 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + ts-morph@22.0.0: + resolution: {integrity: sha512-M9MqFGZREyeb5fTl6gNHKZLqBQA0TjA1lea+CR48R8EBTDuWrNqW6ccC5QvjNR4s6wDumD3LTCjOFSp9iwlzaw==} + tsconfig-paths@4.2.0: resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} engines: {node: '>=6'} @@ -6166,6 +6186,13 @@ snapshots: dependencies: '@testing-library/dom': 9.3.4 + '@ts-morph/common@0.23.0': + dependencies: + fast-glob: 3.3.2 + minimatch: 9.0.4 + mkdirp: 3.0.1 + path-browserify: 1.0.1 + '@types/aria-query@5.0.4': {} '@types/babel__core@7.20.5': @@ -6714,6 +6741,8 @@ snapshots: clone@1.0.4: {} + code-block-writer@13.0.1: {} + color-convert@1.9.3: dependencies: color-name: 1.1.3 @@ -7866,6 +7895,8 @@ snapshots: mkdirp@1.0.4: {} + mkdirp@3.0.1: {} + mlly@1.5.0: dependencies: acorn: 8.11.3 @@ -8031,6 +8062,8 @@ snapshots: parseurl@1.3.3: {} + path-browserify@1.0.1: {} + path-exists@3.0.0: {} path-exists@4.0.0: {} @@ -8740,6 +8773,11 @@ snapshots: ts-interface-checker@0.1.13: {} + ts-morph@22.0.0: + dependencies: + '@ts-morph/common': 0.23.0 + code-block-writer: 13.0.1 + tsconfig-paths@4.2.0: dependencies: json5: 2.2.3 diff --git a/zui/src/index.ts b/zui/src/index.ts index 2028b0dc..ab85b61e 100644 --- a/zui/src/index.ts +++ b/zui/src/index.ts @@ -2,6 +2,11 @@ import { jsonSchemaToZui } from './transforms/json-schema-to-zui' import { zuiToJsonSchema } from './transforms/zui-to-json-schema' import { objectToZui } from './transforms/object-to-zui' import { toTypescriptTypings } from './transforms/zui-to-typescript' +import { + toTypescript, + UntitledDeclarationError, + TypescriptGenerationOptions, +} from './transforms/zui-to-typescript-next' export type { BaseType, @@ -23,5 +28,11 @@ export const transforms = { jsonSchemaToZui, zuiToJsonSchema, objectToZui, + toTypescript, + /** + * @deprecated use toTypescript instead + */ zuiToTypescriptTypings: toTypescriptTypings, } + +export { UntitledDeclarationError, type TypescriptGenerationOptions } diff --git a/zui/src/setup.test.ts b/zui/src/setup.test.ts new file mode 100644 index 00000000..106a0bad --- /dev/null +++ b/zui/src/setup.test.ts @@ -0,0 +1,49 @@ +import { expect } from 'vitest' +import { Project, Diagnostic } from 'ts-morph' + +export function isValidTypescript( + code: string, +): { isValid: true } | { isValid: false; diagnostics: Diagnostic[]; errorMessage: string } { + const project = new Project({}) + try { + project.createSourceFile('test.ts', code) + const diags = project.getPreEmitDiagnostics() + if (diags.length) { + return { isValid: false, diagnostics: diags, errorMessage: project.formatDiagnosticsWithColorAndContext(diags) } + } + return { isValid: true } + } catch (e: any) { + return { isValid: false, diagnostics: [], errorMessage: e?.message || '' } + } +} + +expect.extend({ + toBeValidTypeScript(received: string) { + const { isNot } = this + const validation = isValidTypescript(received) + + return { + pass: validation.isValid, + message: () => { + return `Expected code to ${isNot ? 'not ' : ''}be valid TypeScript:\n${received}\n\n${validation.isValid ? '' : validation.errorMessage}` + }, + } + }, +}) + +expect.extend({ + toMatchWithoutFormatting(received: string, expected: string, _) { + const { isNot } = this + const transformedReceived = received.replace(/\s+/g, '') + const transformedExpected = expected.replace(/\s+/g, '') + + return { + pass: transformedExpected === transformedReceived, + message: () => { + const message = isNot ? 'not ' : '' + const diffView = this.utils.diff(transformedExpected, transformedReceived, { expand: true }) + return `Expected output to ${message}match without formatting:\n${diffView}` + }, + } + }, +}) diff --git a/zui/src/transforms/zui-to-typescript-next/index.test.ts b/zui/src/transforms/zui-to-typescript-next/index.test.ts new file mode 100644 index 00000000..307c8b6f --- /dev/null +++ b/zui/src/transforms/zui-to-typescript-next/index.test.ts @@ -0,0 +1,594 @@ +import { describe, it, expect } from 'vitest' +import { UntitledDeclarationError, toTypescript } from '.' +import z from '../../z' + +describe('functions', () => { + it('title mandatory to declare', async () => { + const fn = z + .function() + .args(z.object({ a: z.number(), b: z.number() })) + .returns(z.number()) + .describe('Add two numbers together.\nThis is a multiline description') + expect(() => toTypescript(fn, { declaration: true })).toThrowError(UntitledDeclarationError) + }) + + it('function with multi-line description', async () => { + const fn = z + .function() + .args(z.object({ a: z.number(), b: z.number() })) + .returns(z.number()) + .title('add') + .describe('Add two numbers together.\nThis is a multiline description') + + const typings = toTypescript(fn, { declaration: true }) + + expect(typings).toMatchWithoutFormatting(` + /** + * Add two numbers together. + * This is a multiline description + */ + declare function add(arg0: { a: number; b: number }): number; + `) + + expect(typings).toBeValidTypeScript() + }) + + it('function with no args and unknown return', async () => { + const fn = z.function().title('fn') + + const typings = toTypescript(fn, { declaration: true }) + + expect(typings).toMatchWithoutFormatting('declare function fn(): unknown;') + + expect(typings).toBeValidTypeScript() + }) + + it('function with no args and void return', async () => { + const fn = z.function().title('fn').returns(z.void()) + + const typings = toTypescript(fn, { declaration: true }) + + expect(typings).toMatchWithoutFormatting('declare function fn(): void;') + expect(typings).toBeValidTypeScript() + }) + + it('async function returning union', async () => { + const fn = z + .function() + .title('fn') + .returns(z.promise(z.union([z.number(), z.string()]))) + + const typings = toTypescript(fn, { declaration: true }) + + expect(typings).toMatchWithoutFormatting('declare function fn(): Promise;') + expect(typings).toBeValidTypeScript() + }) + + it('function with multiple args', async () => { + const fn = z + .function() + .title('fn') + .args( + // Arg 1 + z.object({ a: z.number().optional(), b: z.string().title('B').describe('This is B parameter') }), + // Arg 2 + z.number().describe('This is a number'), + // Arg 3 + z.tuple([z.string(), z.number().describe('This is a number')]), + ) + + const typings = toTypescript(fn, { declaration: true }) + + expect(typings).toMatchWithoutFormatting(` + declare function fn( + arg0: { + a?: number; + /** This is B parameter */ + b: string + }, + /** This is a number */ + arg1: number, + arg2: [string, /** This is a number */ number] + ): unknown; + `) + expect(typings).toBeValidTypeScript() + }) + + it('function with optional args', async () => { + const fn = z.function().title('fn').args(z.string().optional()) + const typings = toTypescript(fn, { declaration: true }) + expect(typings).toMatchWithoutFormatting('declare function fn(arg0?: string): unknown;') + expect(typings).toBeValidTypeScript() + }) + + it('string literals', async () => { + const typings = toTypescript( + z.union([z.literal('Hello, world!'), z.literal('Yoyoyoyo')]).describe('yoyoyo\nmultiline'), + ) + expect(typings).toMatchWithoutFormatting(` + /** + * yoyoyo + * multiline + */ + 'Hello, world!' | 'Yoyoyoyo' + `) + }) + + it('function with named args', async () => { + const fn = z.function().title('fn').args(z.string().title('firstName').optional()) + const typings = toTypescript(fn, { declaration: true }) + expect(typings).toMatchWithoutFormatting('declare function fn(firstName?: string): unknown;') + expect(typings).toBeValidTypeScript() + }) + + it('mix of named and unnammed params', async () => { + const fn = z + .function() + .title('fn') + .args(z.string().title('firstName').optional(), z.number(), z.object({ a: z.string() }).title('obj')) + const typings = toTypescript(fn, { declaration: true }) + expect(typings).toMatchWithoutFormatting(` + declare function fn( + firstName?: string, + arg1: number, + obj: { a: string } + ): unknown; + `) + }) + + it('nullables and optionals combined', async () => { + const fn = z + .function() + .title('fn') + .args(z.string().nullable().optional(), z.number().optional()) + .returns(z.string().nullable().optional()) + + const typings = toTypescript(fn, { declaration: true }) + expect(typings).toMatchWithoutFormatting(` + declare function fn( + arg0?: string | null, + arg1?: number + ): string | null | undefined; + `) + }) +}) + +describe('objects', () => { + it('title mandatory to declare', async () => { + const obj = z.object({ a: z.number(), b: z.string() }) + expect(() => toTypescript(obj, { declaration: true })).toThrowError() + }) + + it('normal object', async () => { + const obj = z.object({ a: z.number(), b: z.string() }).title('MyObject') + + const typings = toTypescript(obj, { declaration: true }) + + expect(typings).toMatchWithoutFormatting('declare const MyObject: { a: number; b: string };') + }) + + it('object with title and description', async () => { + const obj = z + .object({ a: z.number(), b: z.string() }) + .title('MyObject') + .describe('This is my object.\nThis is a multiline description.\n\n\n') + + const typings = toTypescript(obj, { declaration: true }) + + expect(typings).toMatchWithoutFormatting(` + /** + * This is my object. + * This is a multiline description. + */ + declare const MyObject: { a: number; b: string }; + `) + }) + + it('nullable', async () => { + const obj = z.object({ a: z.number(), b: z.string() }).title('MyObject').nullable() + + const typings = toTypescript(obj, { declaration: true }) + + expect(typings).toMatchWithoutFormatting('declare const MyObject: { a: number; b: string } | null;') + }) + + it('optionals with default values', async () => { + const obj = z.object({ a: z.number(), b: z.string() }).title('MyObject').optional().default({ a: 1, b: 'hello' }) + + const typings = toTypescript(obj, { declaration: true }) + + expect(typings).toMatchWithoutFormatting('declare const MyObject: { a: number; b: string } | undefined;') + expect(typings).toBeValidTypeScript() + }) + + it('enum', async () => { + const obj = z.object({ a: z.enum(['hello', 'world']) }).title('MyObject') + + const typings = toTypescript(obj, { declaration: true }) + + expect(typings).toMatchWithoutFormatting(` + declare const MyObject: { + a: 'hello' | 'world' + }; + `) + expect(typings).toBeValidTypeScript() + }) + + it('object with a description & optional', async () => { + const obj = z + .object({ + someStr: z.string().describe('Description').optional(), + }) + .title('MyObject') + + const typings = toTypescript(obj, { declaration: true }) + + expect(typings).toMatchWithoutFormatting(` + declare const MyObject: { + /** Description */ + someStr?: string + }; + `) + + expect(typings).toBeValidTypeScript() + }) + + it('object with optional and a description (opposite of previous test)', async () => { + const obj = z + .object({ + someStr: z.string().optional().describe('Description'), + }) + .title('MyObject') + + const typings = toTypescript(obj, { declaration: true }) + + expect(typings).toMatchWithoutFormatting(` + declare const MyObject: { + /** Description */ + someStr?: string + }; + `) + expect(typings).toBeValidTypeScript() + }) + + it('object with nullable object and no properties', async () => { + const obj = z + .object({ + address: z.object({}).nullable(), + }) + .title('MyObject') + + const typings = toTypescript(obj, { declaration: true }) + + expect(typings).toMatchWithoutFormatting('declare const MyObject: { address: {} | null };') + expect(typings).toBeValidTypeScript() + }) + + it('zod record', async () => { + const obj = z + .object({ + address: z + .record( + z.number(), + z.object({ + street: z.string(), + number: z.number(), + }), + ) + .describe('This is a record'), + }) + .title('MyObject') + + const typings = toTypescript(obj, { declaration: true }) + + expect(typings).toMatchWithoutFormatting(` + declare const MyObject: { + /** This is a record */ + address: { [key: number]: { street: string; number: number } } + }; + `) + + expect(typings).toBeValidTypeScript() + }) + + it('zod record with an optional object', async () => { + const obj = z + .object({ + computed: z.record( + z.string(), + z + .object({ + status: z.string(), + error: z.string().optional(), + }) + .optional(), + ), + }) + .title('MyObject') + + const typings = toTypescript(obj, { declaration: true }) + + expect(typings).toMatchWithoutFormatting( + ` + declare const MyObject: { + computed: { [key: string]: { status: string; error?: string } | undefined } + }; + `, + ) + expect(typings).toBeValidTypeScript() + }) + + it('Can handle a complex discriminated union with descriptions', () => { + const obj = z + .discriminatedUnion('type', [ + z.object({ + type: z.literal('Credit Card'), + cardNumber: z + .string() + .title('Credit Card Number') + .placeholder('1234 5678 9012 3456') + .describe('This is the card number'), + expirationDate: z + .string() + .title('Expiration Date') + .placeholder('10/29') + .describe('This is the expiration date'), + brand: z + .enum(['Visa', 'Mastercard', 'American Express']) + .nullable() + .optional() + .default('Visa') + .describe('This is the brand of the card'), + }), + z.object({ + type: z.literal('PayPal'), + email: z + .string() + .email() + .title('Paypal Email') + .placeholder('john@doe.com') + .describe("This is the paypal account's email address"), + }), + z.object({ + type: z.literal('Bitcoin'), + address: z + .string() + .title('Bitcoin Address') + .placeholder('1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa') + .describe('This is the bitcoin address'), + }), + z.object({ + type: z.literal('Bank Transfer'), + accountNumber: z + .string() + .title('Account Number') + .placeholder('1234567890') + .describe('This is the bank account number'), + }), + z + .object({ + type: z.literal('Cash'), + amount: z + .number() + .title('Amount') + .disabled((value) => (value || 0) > 100) + .describe('This is the amount of cash'), + }) + .disabled((obj) => { + return { + type: !!obj && obj.amount > 100, + } + }) + .disabled(() => { + return false + }), + ]) + .title('payment') + const typings = obj.toTypescript({ declaration: true }) + + expect(typings).toBeValidTypeScript() + }) + it('zod lazy', async () => { + const obj = z + .object({ + address: z.lazy(() => + z + .record( + z.number(), + z.object({ + street: z.string(), + number: z.number(), + }), + ) + .describe('This is a record'), + ), + }) + .title('MyObject') + + const typings = toTypescript(obj, { declaration: true }) + + expect(typings).toMatchWithoutFormatting(` + declare const MyObject: { + address: /** This is a record */ { + [key: number]: { street: string; number: number } + } + }; + `) + + expect(typings).toBeValidTypeScript() + }) + + it('array of complex object as input params', async () => { + const fn = z + .function() + .args(z.array(z.object({ a: z.number(), b: z.string() }))) + .title('MyObject') + const typings = toTypescript(fn, { declaration: true }) + + expect(typings).toMatchWithoutFormatting( + 'declare function MyObject(arg0: Array<{ a: number; b: string }>): unknown;', + ) + expect(typings).toBeValidTypeScript() + }) + + it('array of primitives as input params', async () => { + const fn = z.function().args(z.array(z.number()).describe('This is an array of numbers')).title('MyObject') + const typings = toTypescript(fn, { declaration: true }) + + expect(typings).toMatchWithoutFormatting(` + declare function MyObject( + /** This is an array of numbers */ + arg0: number[] + ): unknown; + `) + expect(typings).toBeValidTypeScript() + }) + + it('zod effects', async () => { + const obj = z + .object({ + a: z + .string() + .title('A') + .describe('This is A') + .transform((val) => val.toUpperCase()), + }) + .title('MyObject') + + const typings = toTypescript(obj, { declaration: true }) + + expect(typings).toMatchWithoutFormatting(` + declare const MyObject: { + /** This is A */ + a: /* + * This is A + */ + string + }; + `) + + expect(typings).toBeValidTypeScript() + }) + + it('zod effects', async () => { + const obj = z + .object({ + 'Hello World!': z.string(), + 'Hey?': z.string().optional(), + 'Hey!': z.string().optional(), + }) + .title('MyObject') + + const typings = toTypescript(obj, { declaration: true }) + + expect(typings).toMatchWithoutFormatting(` + declare const MyObject: { + 'Hello World!': string; + 'Hey?'?: string; + 'Hey!'?: string + }; + `) + + expect(typings).toBeValidTypeScript() + }) +}) + +function getTypingVariations(type: z.ZodType, opts?: { declaration?: boolean; maxDepth?: number }): string[] { + const baseTypings = toTypescript(type, opts) + + const typingsNullable = toTypescript(type.nullable(), opts) + + const typingsOptional = toTypescript(type.optional(), opts) + + const typingsNullableOptional = toTypescript(type.nullable().optional(), opts) + + const typingsOptionalNullable = toTypescript(type.optional().nullable(), opts) + + const output = [baseTypings, typingsNullable, typingsOptional, typingsNullableOptional, typingsOptionalNullable] + + return output +} + +describe('primitives', () => { + it.concurrent.each(getTypingVariations(z.string().title('MyString'), { declaration: true }))( + 'string', + (typings) => { + expect(typings).toBeValidTypeScript() + }, + 5000, + ) + it.concurrent.each(getTypingVariations(z.number().title('MyNumber'), { declaration: true }))( + 'number', + (typings) => { + expect(typings).toBeValidTypeScript() + }, + 5000, + ) + it.concurrent.each(getTypingVariations(z.bigint().title('MyBigInt'), { declaration: true }))( + 'int', + (typings) => { + expect(typings).toBeValidTypeScript() + }, + 5000, + ) + it.concurrent.each(getTypingVariations(z.boolean().title('MyBoolean'), { declaration: true }))( + 'boolean', + (typings) => { + expect(typings).toBeValidTypeScript() + }, + 5000, + ) + it.concurrent.each(getTypingVariations(z.date().title('MyDate'), { declaration: true }))( + 'date', + (typings) => { + console.log(typings) + expect(typings).toBeValidTypeScript() + }, + 5000, + ) + it.concurrent.each(getTypingVariations(z.undefined().title('MyUndefined'), { declaration: true }))( + 'undefined', + (typings) => { + expect(typings).toBeValidTypeScript() + }, + 5000, + ) + it.concurrent.each(getTypingVariations(z.null().title('MyNull'), { declaration: true }))('null', (typings) => { + expect(typings).toBeValidTypeScript() + }) + it.concurrent.each(getTypingVariations(z.unknown().title('MyUnknown'), { declaration: true }))( + 'unknown', + (typings) => { + expect(typings).toBeValidTypeScript() + }, + 5000, + ) + it.concurrent.each(getTypingVariations(z.never().title('MyNever'), { declaration: true }))( + 'never', + (typings) => { + expect(typings).toBeValidTypeScript() + }, + 5000, + ) + it.concurrent.each(getTypingVariations(z.nan().title('MyNaNBreadMiam'), { declaration: true }))( + 'nan', + (typings) => { + expect(typings).toBeValidTypeScript() + }, + 5000, + ) + it.concurrent.each(getTypingVariations(z.symbol().title('MySymbol'), { declaration: true }))( + 'symbol', + (typings) => { + expect(typings).toBeValidTypeScript() + }, + 5000, + ) + it.concurrent.each(getTypingVariations(z.literal('bob').title('MYLiteral'), { declaration: true }))( + 'function', + (typings) => { + expect(typings).toBeValidTypeScript() + }, + 5000, + ) +}) diff --git a/zui/src/transforms/zui-to-typescript-next/index.ts b/zui/src/transforms/zui-to-typescript-next/index.ts new file mode 100644 index 00000000..6e4f99bb --- /dev/null +++ b/zui/src/transforms/zui-to-typescript-next/index.ts @@ -0,0 +1,352 @@ +import z, { util } from '../../z' +import { escapeString, getMultilineComment, toPropertyKey } from './utils' + +const Primitives = [ + 'string', + 'number', + 'boolean', + 'unknown', + 'void', + 'any', + 'null', + 'undefined', + 'never', + 'bigint', + 'symbol', + 'object', +] + +export class UntitledDeclarationError extends Error { + constructor(message: string) { + super(message) + this.name = 'UntitledDeclarationError' + } + + static isUntitledDeclarationError(err: Error): err is UntitledDeclarationError { + return err.name === 'UntitledDeclarationError' + } +} + +const isPrimitive = (type: string) => Primitives.includes(type) +const isArrayOfPrimitives = (type: string) => Primitives.map((p) => `${p}[]`).includes(type) + +const stripSpaces = (typings: string) => typings.replace(/ +/g, ' ').trim() + +class KeyValue { + constructor( + public key: string, + public value: z.Schema, + ) {} +} + +class FnParameters { + constructor(public schema: z.Schema) {} +} + +class FnReturn { + constructor(public schema: z.Schema) {} +} + +class Declaration { + constructor( + public schema: z.Schema, + public identifier: string, + ) {} +} + +export type TypescriptGenerationOptions = { + declaration?: boolean + formatters?: ((typing: string) => string)[] +} + +type SchemaTypes = z.Schema | KeyValue | FnParameters | Declaration | null + +type InternalOptions = TypescriptGenerationOptions & { + parent?: SchemaTypes +} + +export function toTypescript(schema: z.Schema, options?: TypescriptGenerationOptions): string { + options ??= {} + options.declaration ??= false + + let wrappedSchema: z.Schema | Declaration = schema + + if (options?.declaration) { + if (schema instanceof z.Schema) { + const title = 'title' in schema.ui ? (schema.ui.title as string) : null + if (!title) { + throw new UntitledDeclarationError('Only schemas with "title" Zui property can be declared.') + } + + wrappedSchema = new Declaration(schema, title) + } + } + + let dts = sUnwrapZod(wrappedSchema, { ...options }) + + if (options.formatters?.length) { + for (const formatter of options.formatters) { + dts = formatter(dts) + } + } + + return dts +} + +function sUnwrapZod(schema: z.Schema | KeyValue | FnParameters | Declaration | null, config: InternalOptions): string { + const newConfig = { + ...config, + declaration: false, + parent: schema, + } + if (schema === null) { + return '' + } + + if (schema instanceof Declaration) { + const description = getMultilineComment(schema.schema.description) + const withoutDesc = schema.schema.describe('') + const typings = sUnwrapZod(withoutDesc, { ...newConfig, declaration: true }) + + if (schema.schema instanceof z.ZodFunction) { + return stripSpaces(`${description} +declare function ${schema.identifier}${typings};`) + } + + return stripSpaces(`${description} +declare const ${schema.identifier}: ${typings};`) + } + + if (schema instanceof KeyValue) { + if (schema.value instanceof z.ZodOptional) { + let innerType = schema.value._def.innerType as z.Schema + if (innerType instanceof z.Schema && !innerType.description && schema.value.description) { + innerType = innerType?.describe(schema.value.description) + } + return sUnwrapZod(new KeyValue(schema.key + '?', innerType), newConfig) + } + + const description = getMultilineComment(schema.value._def.description) + const delimiter = description?.trim().length > 0 ? '\n' : '' + const withoutDesc = schema.value.describe('') + + return `${delimiter}${description}${delimiter}${schema.key}: ${sUnwrapZod(withoutDesc, newConfig)}${delimiter}` + } + + if (schema instanceof FnParameters) { + if (schema.schema instanceof z.ZodTuple) { + let args = '' + for (let i = 0; i < schema.schema.items.length; i++) { + const argName = schema.schema.items[i]?.ui?.title ?? `arg${i}` + const item = schema.schema.items[i] + args += `${sUnwrapZod(new KeyValue(toPropertyKey(argName), item), newConfig)}${i < schema.schema.items.length - 1 ? ', ' : ''} ` + } + + return args + } + + const typings = sUnwrapZod(schema.schema, newConfig) + + const startsWithPairs = + (typings.startsWith('{') && typings.endsWith('}')) || + (typings.startsWith('[') && typings.endsWith(']')) || + (typings.startsWith('(') && typings.endsWith(')')) || + (typings.startsWith('Array<') && typings.endsWith('>')) || + (typings.startsWith('Record<') && typings.endsWith('>')) || + isArrayOfPrimitives(typings) + + if (startsWithPairs) { + return `args: ${typings}` + } else { + return typings + } + } + + if (schema instanceof FnReturn) { + if (schema.schema instanceof z.ZodOptional) { + return `${sUnwrapZod(schema.schema.unwrap(), newConfig)} | undefined` + } + + return sUnwrapZod(schema.schema, newConfig) + } + + const schemaTyped = schema as z.ZodFirstPartySchemaTypes + const def = schemaTyped._def + + switch (def.typeName) { + case z.ZodFirstPartyTypeKind.ZodString: + return `${getMultilineComment(def.description)} string`.trim() + + case z.ZodFirstPartyTypeKind.ZodNumber: + case z.ZodFirstPartyTypeKind.ZodNaN: + case z.ZodFirstPartyTypeKind.ZodBigInt: + return `${getMultilineComment(def.description)} number`.trim() + + case z.ZodFirstPartyTypeKind.ZodBoolean: + return `${getMultilineComment(schema._def.description)} boolean`.trim() + + case z.ZodFirstPartyTypeKind.ZodDate: + return `${getMultilineComment(def.description)} Date`.trim() + + case z.ZodFirstPartyTypeKind.ZodUndefined: + return `${getMultilineComment(def.description)} undefined`.trim() + + case z.ZodFirstPartyTypeKind.ZodNull: + return `${getMultilineComment(def.description)} null`.trim() + + case z.ZodFirstPartyTypeKind.ZodAny: + return `${getMultilineComment(def.description)} any`.trim() + + case z.ZodFirstPartyTypeKind.ZodUnknown: + return `${getMultilineComment(def.description)} unknown`.trim() + + case z.ZodFirstPartyTypeKind.ZodNever: + return `${getMultilineComment(def.description)} never`.trim() + + case z.ZodFirstPartyTypeKind.ZodVoid: + return `${getMultilineComment(def.description)} void`.trim() + + case z.ZodFirstPartyTypeKind.ZodArray: + const item = sUnwrapZod(def.type, newConfig) + + if (isPrimitive(item)) { + return `${item}[]` + } + + return `Array<${item}>` + + case z.ZodFirstPartyTypeKind.ZodObject: + const props = Object.entries((schema as z.ZodObject).shape).map(([key, value]) => { + if (value instanceof z.Schema) { + return sUnwrapZod(new KeyValue(toPropertyKey(key), value), newConfig) + } + return `${key}: unknown` + }) + + return `{ ${props.join('; ')} }` + case z.ZodFirstPartyTypeKind.ZodUnion: + const options = ((schema as z.ZodUnion).options as z.ZodSchema[]).map((option) => { + return sUnwrapZod(option, newConfig) + }) + return `${getMultilineComment(def.description)} +${options.join(' | ')}` + + case z.ZodFirstPartyTypeKind.ZodDiscriminatedUnion: + const opts = ((schema as z.ZodDiscriminatedUnion).options as z.ZodSchema[]).map((option) => { + return sUnwrapZod(option, newConfig) + }) + return `${getMultilineComment(schema._def.description)} +${opts.join(' | ')}` + + case z.ZodFirstPartyTypeKind.ZodIntersection: + return `${sUnwrapZod(def.left, newConfig)} & ${sUnwrapZod(def.right, newConfig)}` + + case z.ZodFirstPartyTypeKind.ZodTuple: + if (def.items.length === 0) { + return '' + } + + const items = def.items.map((i: any) => sUnwrapZod(i, newConfig)) + return `[${items.join(', ')}]` + + case z.ZodFirstPartyTypeKind.ZodRecord: + const keyType = sUnwrapZod(def.keyType, newConfig) + const valueType = sUnwrapZod(def.valueType, newConfig) + return `${getMultilineComment(def.description)} { [key: ${keyType}]: ${valueType} }` + + case z.ZodFirstPartyTypeKind.ZodMap: + return `Map<${sUnwrapZod(def.keyType, newConfig)}, ${sUnwrapZod(def.valueType, newConfig)}>` + + case z.ZodFirstPartyTypeKind.ZodSet: + return `Set<${sUnwrapZod(def.valueType, newConfig)}>` + + case z.ZodFirstPartyTypeKind.ZodFunction: + const input = sUnwrapZod(new FnParameters(def.args), newConfig) + const output = sUnwrapZod(new FnReturn(def.returns), newConfig) + + if (config?.declaration) { + return `${getMultilineComment(def.description)} +(${input}): ${output}` + } + + return `${getMultilineComment(def.description)} +(${input}) => ${output}` + + case z.ZodFirstPartyTypeKind.ZodLazy: + return sUnwrapZod(def.getter(), newConfig) + + case z.ZodFirstPartyTypeKind.ZodLiteral: + return `${getMultilineComment(def.description)} +${escapeString((schema as z.ZodLiteral).value)}`.trim() + + case z.ZodFirstPartyTypeKind.ZodEnum: + const values = def.values.map(escapeString) + return values.join(' | ') + + case z.ZodFirstPartyTypeKind.ZodEffects: + return sUnwrapZod(def.schema, newConfig) + + case z.ZodFirstPartyTypeKind.ZodNativeEnum: + return sUnwrapZod(def.values, newConfig) + + case z.ZodFirstPartyTypeKind.ZodOptional: + if (config?.declaration || config?.parent instanceof z.ZodRecord || config?.parent instanceof z.ZodObject) { + return `${sUnwrapZod(def.innerType, newConfig)} | undefined` + } + + if ( + config?.parent instanceof z.ZodDefault || + config?.parent instanceof z.ZodNullable || + config?.parent instanceof z.ZodOptional + ) { + return `${sUnwrapZod(def.innerType, newConfig)} | undefined` + } + + return `${sUnwrapZod(def.innerType, newConfig)}?` + + case z.ZodFirstPartyTypeKind.ZodNullable: + return `${sUnwrapZod((schema as z.ZodNullable).unwrap(), newConfig)} | null` + + case z.ZodFirstPartyTypeKind.ZodDefault: + return sUnwrapZod(def.innerType, newConfig) + + case z.ZodFirstPartyTypeKind.ZodCatch: + return sUnwrapZod((schema as z.ZodCatch).removeCatch(), newConfig) + + case z.ZodFirstPartyTypeKind.ZodPromise: + return `Promise<${sUnwrapZod((schema as z.ZodPromise).unwrap(), newConfig)}>` + + case z.ZodFirstPartyTypeKind.ZodBranded: + return sUnwrapZod(def.type, newConfig) + + case z.ZodFirstPartyTypeKind.ZodPipeline: + return sUnwrapZod(def.in, newConfig) + + case z.ZodFirstPartyTypeKind.ZodSymbol: + return `${getMultilineComment(def.description)} symbol`.trim() + + case z.ZodFirstPartyTypeKind.ZodReadonly: + return `readonly ${sUnwrapZod(def.innerType, newConfig)}` + + case z.ZodFirstPartyTypeKind.ZodTemplateLiteral: + const inner = def.parts + .map((p) => { + if (typeof p === 'undefined' || p === null) { + return '' + } + if (typeof p === 'string') { + return p + } + if (typeof p === 'boolean' || typeof p === 'number') { + return `${p}` + } + return '${' + sUnwrapZod(p, { ...newConfig, declaration: false }) + '}' + }) + .join('') + + return `\`${inner}\`` + + default: + util.assertNever(def) + } +} diff --git a/zui/src/transforms/zui-to-typescript-next/utils.test.ts b/zui/src/transforms/zui-to-typescript-next/utils.test.ts new file mode 100644 index 00000000..4fcd7d1a --- /dev/null +++ b/zui/src/transforms/zui-to-typescript-next/utils.test.ts @@ -0,0 +1,80 @@ +import { isValidTypescript } from '../../setup.test' +import { expect } from 'vitest' +import { escapeString } from './utils' + +describe('Typescript Checker', () => { + it('passes successfully on valid string definition', () => { + const data = isValidTypescript(`const a: string = 'hello'`) + + expect(data.isValid).toBe(true) + }) + + it('fails correctly on invalid code', () => { + const data = isValidTypescript(`const a: string = 1`) + expect(data.isValid).toBe(false) + }) + + it('can handle Error types', () => { + const data = isValidTypescript(`const a: Error = new Error('hello')`) + expect(data.isValid).toBe(true) + }) + + it('can handle promises', () => { + const data = isValidTypescript(`const a: Promise = Promise.resolve('hello')`) + expect(data.isValid).toBe(true) + }) +}) + +describe('test utility to validate typescript', () => { + it('passes on valid code', () => { + const exampleTS = ` +const a: string = 'hello' +const b: number = 1 +const c: string[] = ['hello'] +const d: { a: string } = { a: 'hello' } +const e: { a: string }[] = [{ a: 'hello' }]` + expect(exampleTS).toBeValidTypeScript() + }) + + it('fails on invalid code', () => { + const invalidTS = ` +const a: string = 1 +const b: number = 'hello' +const c: string[] = [1] +const d: { a: string } = { a: 1 } + + })` + expect(invalidTS).not.toBeValidTypeScript() + }) +}) + +describe('Escape String', () => { + it('escapes a string containing nothing special', () => { + expect(escapeString('hello')).toBe("'hello'") + }) + + it('escapes a string containing single quotes', () => { + expect(escapeString("'hello'")).toMatchInlineSnapshot(`"'\\'hello\\''"`) + }) + + it('escapes a string containing double quotes', () => { + const world = 'world' + expect(escapeString(`"Hey ${world}"`)).toMatchInlineSnapshot(`"'"Hey world"'"`) + }) + + it('escapes a string containing double quotes', () => { + expect( + escapeString(` +\`\`\` +Hey world +\`\`\` +`), + ).toMatchInlineSnapshot(` + "" + \`\`\` + Hey world + \`\`\` + "" + `) + }) +}) diff --git a/zui/src/transforms/zui-to-typescript-next/utils.ts b/zui/src/transforms/zui-to-typescript-next/utils.ts new file mode 100644 index 00000000..fa614a03 --- /dev/null +++ b/zui/src/transforms/zui-to-typescript-next/utils.ts @@ -0,0 +1,46 @@ +import _ from 'lodash' +export function escapeString(str: string) { + if (typeof str !== 'string') { + return '' + } + + // Use String.raw to get the raw string with escapes preserved + const rawStr = String.raw`${str}` + + // Determine the appropriate quote style + if (rawStr.includes('`')) { + return `"${rawStr.replace(/"/g, '\\"')}"` + } else if (rawStr.includes("'")) { + return `'${rawStr.replace(/'/g, "\\'")}'` + } else { + return `'${rawStr}'` + } +} + +export const toPropertyKey = (key: string) => { + if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key)) { + return key + } + + return escapeString(key) +} + +export const getMultilineComment = (description?: string) => { + const descLines = (description ?? '').split('\n').filter((l) => l.trim().length > 0) + return descLines.length === 0 + ? '' + : descLines.length === 1 + ? `/** ${descLines[0]} */` + : `/**\n * ${descLines.join('\n * ')}\n */` +} + +export const toValidFunctionName = (str: string) => { + let name = _.deburr(str) + name = name.replace(/[^a-zA-Z0-9_$]/g, '') + + if (!/^[a-zA-Z_$]/.test(name)) { + name = `_${name}` + } + + return _.camelCase(name) +} diff --git a/zui/src/vitest.d.ts b/zui/src/vitest.d.ts new file mode 100644 index 00000000..c1747283 --- /dev/null +++ b/zui/src/vitest.d.ts @@ -0,0 +1,11 @@ +import type { Assertion, AsymmetricMatchersContaining } from 'vitest' + +interface CustomMatchers { + toMatchWithoutFormatting: (expected: string) => R + toBeValidTypeScript: () => R +} + +declare module 'vitest' { + interface Assertion extends CustomMatchers {} + interface AsymmetricMatchersContaining extends CustomMatchers {} +} diff --git a/zui/src/z/types/basetype/index.ts b/zui/src/z/types/basetype/index.ts index 451bca2f..4c0420e2 100644 --- a/zui/src/z/types/basetype/index.ts +++ b/zui/src/z/types/basetype/index.ts @@ -48,6 +48,7 @@ import { import type { ZuiSchemaOptions } from '../../../transforms/zui-to-json-schema/zui-extension' import type { ObjectToZuiOptions } from '../../../transforms/object-to-zui' import { type ToTypescriptTyingsOptions, toTypescriptTypings } from '../../../transforms/zui-to-typescript' +import { TypescriptGenerationOptions, toTypescript } from '../../../transforms/zui-to-typescript-next' export type RefinementCtx = { addIssue: (arg: IssueData) => void @@ -121,11 +122,13 @@ export type RawCreateParams = invalid_type_error?: string required_error?: string description?: string + [zuiKey]?: any } | undefined export type ProcessedCreateParams = { errorMap?: ZodErrorMap description?: string + [zuiKey]?: any } export type SafeParseSuccess = { success: true @@ -563,10 +566,29 @@ export abstract class ZodType { return toTypescriptTypings(this.toJsonSchema(), opts) } + toTypescript(opts?: TypescriptGenerationOptions): string { + return toTypescript(this, opts) + } + + async toTypescriptAsync( + opts?: Omit & { + formatters: ((typing: string) => Promise | string)[] + }, + ): Promise { + let result = toTypescript(this, { ...opts, formatters: [] }) + for (const formatter of opts?.formatters || []) { + result = await formatter(result) + } + return result + } + static fromObject(obj: any, opts?: ObjectToZuiOptions) { return objectToZui(obj, opts) } diff --git a/zui/src/z/types/defs.ts b/zui/src/z/types/defs.ts index f37e0be6..e11cc324 100644 --- a/zui/src/z/types/defs.ts +++ b/zui/src/z/types/defs.ts @@ -43,6 +43,13 @@ export type ZodDef = | ZodDateDef | ZodUndefinedDef | ZodNullDef + | ZodDefaultDef + | ZodCatchDef + | ZodTemplateLiteralDef + | ZodReadonlyDef + | ZodDiscriminatedUnionDef + | ZodBrandedDef + | ZodPipelineDef | ZodAnyDef | ZodUnknownDef | ZodNeverDef diff --git a/zui/src/z/types/utils/index.ts b/zui/src/z/types/utils/index.ts index c56e69e9..aac866ef 100644 --- a/zui/src/z/types/utils/index.ts +++ b/zui/src/z/types/utils/index.ts @@ -1,3 +1,4 @@ +import { zuiKey } from '../../../ui/constants' import type { ZodErrorMap } from '../error' import type { ProcessedCreateParams, RawCreateParams } from '../index' @@ -8,7 +9,7 @@ export namespace util { export const assertEqual = (val: AssertEqual) => val export function assertIs(_arg: T): void {} export function assertNever(_x: never): never { - throw new Error() + throw new Error('assertNever called') } export type Omit = Pick> @@ -200,11 +201,15 @@ export const getParsedType = (data: any): ZodParsedType => { } export function processCreateParams(params: RawCreateParams): ProcessedCreateParams { if (!params) return {} + const { errorMap, invalid_type_error, required_error, description } = params + if (errorMap && (invalid_type_error || required_error)) { throw new Error(`Can't use "invalid_type_error" or "required_error" in conjunction with custom error map.`) } - if (errorMap) return { errorMap: errorMap, description } + + if (errorMap) return { errorMap: errorMap, description, [zuiKey]: params?.[zuiKey] } + const customMap: ZodErrorMap = (iss, ctx) => { if (iss.code !== 'invalid_type') return { message: ctx.defaultError } if (typeof ctx.data === 'undefined') { @@ -212,5 +217,5 @@ export function processCreateParams(params: RawCreateParams): ProcessedCreatePar } return { message: invalid_type_error ?? ctx.defaultError } } - return { errorMap: customMap, description } + return { errorMap: customMap, description, [zuiKey]: params?.[zuiKey] } } diff --git a/zui/tsconfig.json b/zui/tsconfig.json index 2f1658d8..eec26374 100644 --- a/zui/tsconfig.json +++ b/zui/tsconfig.json @@ -30,6 +30,7 @@ "**/benchmark/**/*.ts", ], "include": [ - "src/**/*" + "src/**/*", + "vitest.d.ts" ] } \ No newline at end of file diff --git a/zui/vite.config.ts b/zui/vite.config.ts index fcb0997f..c7f1ffcc 100644 --- a/zui/vite.config.ts +++ b/zui/vite.config.ts @@ -6,7 +6,9 @@ import react from '@vitejs/plugin-react-swc' export default defineConfig({ plugins: [react()], test: { + setupFiles: ['./src/setup.test.ts'], environment: 'jsdom', globals: true, + exclude: ['**/node_modules/**', './src/setup.test.ts'], }, })