Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

fix(zui): support transform of a default array to typescript schema #472

Merged
merged 7 commits into from
Nov 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion zui/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@bpinternal/zui",
"version": "0.13.1",
"version": "0.13.2",
"description": "A fork of Zod with additional features",
"type": "module",
"source": "./src/index.ts",
Expand Down
2 changes: 1 addition & 1 deletion zui/src/transforms/common/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export class ZuiToTypescriptTypeError extends ZuiTransformError {
}
}
export class UnsupportedZuiToTypescriptTypeError extends ZuiToTypescriptTypeError {
public constructor(type: ZodFirstPartyTypeKind | string) {
public constructor(type: ZodFirstPartyTypeKind) {
super(`Zod type ${type} cannot be transformed to TypeScript type.`)
}
}
Expand Down
44 changes: 36 additions & 8 deletions zui/src/transforms/zui-to-typescript-schema/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,20 @@ import { describe, expect } from 'vitest'
import { toTypescriptSchema as toTypescript } from '.'
import { evalZuiString } from '../common/eval-zui-string'
import * as errors from '../common/errors'
import z, { ZodType } from '../../z'
import z, { ZodLiteral, ZodSchema, ZodType } from '../../z'

const evalZui = (source: string): ZodSchema => {
const evalResult = evalZuiString(source)
if (!evalResult.sucess) {
throw new Error(`${evalResult.error}: ${source}`)
}
return evalResult.value
}

const assert = (source: ZodType) => ({
toGenerateItself: async () => {
const actual = toTypescript(source)
const evalResult = evalZuiString(actual)
if (!evalResult.sucess) {
throw new Error(evalResult.error)
}
const destination = evalResult.value
const destination = evalZui(actual)
expect(source.isEqual(destination)).toBe(true)
},
toThrowErrorWhenGenerating: async () => {
Expand Down Expand Up @@ -131,10 +135,31 @@ describe('toTypescriptZuiString', () => {
const schema = z.literal(42)
await assert(schema).toGenerateItself()
})
test('literal symbol', async () => {
const source = z.literal(Symbol('banana'))
const dest = evalZui(toTypescript(source)) as ZodLiteral

expect(dest instanceof ZodLiteral).toBe(true)
const value = dest.value as symbol
expect(typeof value).toBe('symbol')
expect(value.description).toBe('banana')
})
test('literal bigint', async () => {
const schema = z.literal(BigInt(42))
await assert(schema).toGenerateItself()
})
test('literal boolean', async () => {
const schema = z.literal(true)
await assert(schema).toGenerateItself()
})
test('literal null', async () => {
const schema = z.literal(null)
await assert(schema).toGenerateItself()
})
test('literal undefined', async () => {
const schema = z.literal(undefined)
await assert(schema).toGenerateItself()
})
test('enum', async () => {
const schema = z.enum(['banana', 'apple', 'orange'])
await assert(schema).toGenerateItself()
Expand All @@ -160,8 +185,11 @@ describe('toTypescriptZuiString', () => {
await assert(schema).toGenerateItself()
})
test('default', async () => {
const schema = z.string().default('banana')
await assert(schema).toGenerateItself()
const schema1 = z.string().default('banana')
await assert(schema1).toGenerateItself()

const schema2 = z.string().array().default(['banana'])
await assert(schema2).toGenerateItself()
})
test('catch', async () => {
const schema = z.string().catch('banana')
Expand Down
18 changes: 11 additions & 7 deletions zui/src/transforms/zui-to-typescript-schema/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import mapValues from 'lodash/mapValues'
import z, { util } from '../../z'
import { escapeString, getMultilineComment } from '../zui-to-typescript-type/utils'
import { mapValues, toTypesriptPrimitive } from './utils'
import {
primitiveToTypescriptValue,
getMultilineComment,
unknownToTypescriptValue,
} from '../zui-to-typescript-type/utils'
import * as errors from '../common/errors'

/**
Expand Down Expand Up @@ -81,7 +85,7 @@ function sUnwrapZod(schema: z.Schema): string {

case z.ZodFirstPartyTypeKind.ZodDiscriminatedUnion:
const opts = (def.options as z.ZodSchema[]).map(sUnwrapZod)
const discriminator = escapeString(def.discriminator)
const discriminator = primitiveToTypescriptValue(def.discriminator)
return `${getMultilineComment(def.description)}z.discriminatedUnion(${discriminator}, [${opts.join(', ')}])`.trim()

case z.ZodFirstPartyTypeKind.ZodIntersection:
Expand Down Expand Up @@ -116,11 +120,11 @@ function sUnwrapZod(schema: z.Schema): string {
return `${getMultilineComment(def.description)}z.lazy(() => ${sUnwrapZod(def.getter())})`.trim()

case z.ZodFirstPartyTypeKind.ZodLiteral:
const value = toTypesriptPrimitive(def.value)
const value = primitiveToTypescriptValue(def.value)
return `${getMultilineComment(def.description)}z.literal(${value})`.trim()

case z.ZodFirstPartyTypeKind.ZodEnum:
const values = def.values.map(toTypesriptPrimitive)
const values = def.values.map(primitiveToTypescriptValue)
return `${getMultilineComment(def.description)}z.enum([${values.join(', ')}])`.trim()

case z.ZodFirstPartyTypeKind.ZodEffects:
Expand All @@ -136,7 +140,7 @@ function sUnwrapZod(schema: z.Schema): string {
return `${getMultilineComment(def.description)}z.nullable(${sUnwrapZod(def.innerType)})`.trim()

case z.ZodFirstPartyTypeKind.ZodDefault:
const defaultValue = toTypesriptPrimitive(def.defaultValue())
const defaultValue = unknownToTypescriptValue(def.defaultValue())
// TODO: use z.default() notation
return `${getMultilineComment(def.description)}${sUnwrapZod(def.innerType)}.default(${defaultValue})`.trim()

Expand All @@ -159,7 +163,7 @@ function sUnwrapZod(schema: z.Schema): string {
return `${getMultilineComment(def.description)}z.readonly(${sUnwrapZod(def.innerType)})`.trim()

case z.ZodFirstPartyTypeKind.ZodRef:
const uri = escapeString(def.uri)
const uri = primitiveToTypescriptValue(def.uri)
return `${getMultilineComment(def.description)}z.ref(${uri})`.trim()

default:
Expand Down
21 changes: 0 additions & 21 deletions zui/src/transforms/zui-to-typescript-schema/utils.ts

This file was deleted.

10 changes: 8 additions & 2 deletions zui/src/transforms/zui-to-typescript-type/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,14 @@ describe.concurrent('functions', () => {

it('bigint literals', async () => {
const n = BigInt(100)
const fn = () => toTypescript(z.literal(n))
expect(fn).toThrowError()
const typings = toTypescript(z.literal(n))
await expect(typings).toMatchWithoutFormatting('declare const x: 100n')
})

it('symbol literals', async () => {
const n = Symbol('hello')
const typings = toTypescript(z.literal(n))
await expect(typings).toMatchWithoutFormatting('declare const x: symbol')
})

it('non explicitly discriminated union', async () => {
Expand Down
15 changes: 9 additions & 6 deletions zui/src/transforms/zui-to-typescript-type/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import z, { util } from '../../z'
import { escapeString, getMultilineComment, toPropertyKey, toTypeArgumentName } from './utils'
import {
primitiveToTypescriptValue,
getMultilineComment,
toPropertyKey,
toTypeArgumentName,
primitiveToTypscriptLiteralType,
} from './utils'
import * as errors from '../common/errors'

const Primitives = [
Expand Down Expand Up @@ -263,15 +269,12 @@ ${opts.join(' | ')}`
return sUnwrapZod(def.getter(), newConfig)

case z.ZodFirstPartyTypeKind.ZodLiteral:
if (typeof def.value === 'bigint') {
throw new errors.UnsupportedZuiToTypescriptTypeError(`${z.ZodFirstPartyTypeKind.ZodLiteral}<bigint>`)
}
const value: string = typeof def.value === 'string' ? escapeString(def.value) : String(def.value)
const value: string = primitiveToTypscriptLiteralType(def.value)
return `${getMultilineComment(def.description)}
${value}`.trim()

case z.ZodFirstPartyTypeKind.ZodEnum:
const values = def.values.map(escapeString)
const values = def.values.map(primitiveToTypescriptValue)
return values.join(' | ')

case z.ZodFirstPartyTypeKind.ZodEffects:
Expand Down
80 changes: 54 additions & 26 deletions zui/src/transforms/zui-to-typescript-type/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { isValidTypescript } from '../../setup.test'
import { expect } from 'vitest'
import { escapeString, toTypeArgumentName } from './utils'
import { toTypeArgumentName, primitiveToTypescriptValue } from './utils'

describe('Typescript Checker', () => {
it('passes successfully on valid string definition', () => {
Expand Down Expand Up @@ -48,34 +48,62 @@ const d: { a: string } = { a: 1 }
})
})

describe('Escape String', () => {
it('escapes a string containing nothing special', () => {
expect(escapeString('hello')).toBe("'hello'")
describe('primitiveToTypscriptLiteral', () => {
it('converts a string to a valid typescript string value', () => {
const input: string = 'hello'
const tsValue: string = primitiveToTypescriptValue(input)
const actual = eval(tsValue)
expect(typeof actual).toEqual('string')
expect(actual).toEqual(input)
})

it('escapes a string containing single quotes', () => {
expect(escapeString("'hello'")).toMatchInlineSnapshot(`"'\\'hello\\''"`)
it('converts a number to a valid typescript number value', () => {
const input: number = 42
const tsValue: string = primitiveToTypescriptValue(input)
const actual = eval(tsValue)
expect(typeof actual).toEqual('number')
expect(actual).toEqual(input)
})

it('escapes a string containing double quotes', () => {
const world = 'world'
expect(escapeString(`"Hey ${world}"`)).toMatchInlineSnapshot(`"'"Hey world"'"`)
it('converts a boolean to a valid typescript boolean value', () => {
const input: boolean = true
const tsValue: string = primitiveToTypescriptValue(input)
const actual = eval(tsValue)
expect(typeof actual).toEqual('boolean')
expect(actual).toEqual(input)
})

it('escapes a string containing double quotes', () => {
expect(
escapeString(`
\`\`\`
Hey world
\`\`\`
`),
).toMatchInlineSnapshot(`
""
\`\`\`
Hey world
\`\`\`
""
`)
it('converts a null to a valid typescript null value', () => {
const input: null = null
const tsValue: string = primitiveToTypescriptValue(input)
const actual = eval(tsValue)
expect(typeof actual).toEqual('object') // null is an object in javascript
expect(actual).toEqual(input)
})
it('converts a symbol with name to a valid typescript symbol value', () => {
const input: symbol = Symbol('hello')
const tsValue: string = primitiveToTypescriptValue(input)
const actual = eval(tsValue)
expect(typeof actual).toEqual('symbol')
expect(actual.description).toEqual(input.description)
})
it('converts a symbol without name to a valid typescript symbol value', () => {
const input: symbol = Symbol()
const tsValue: string = primitiveToTypescriptValue(input)
const actual = eval(tsValue)
expect(typeof actual).toEqual('symbol')
expect(actual.description).toEqual(input.description)
})
it('converts a undefined to a valid typescript undefined value', () => {
const input: undefined = undefined
const tsValue: string = primitiveToTypescriptValue(input)
const actual = eval(tsValue)
expect(typeof actual).toEqual('undefined')
expect(actual).toEqual(input)
})
it('converts a bigint to a valid typescript bigint value', () => {
const input: bigint = BigInt(42)
const tsValue: string = primitiveToTypescriptValue(input)
const actual = eval(tsValue)
expect(typeof actual).toEqual('bigint')
expect(actual).toEqual(input)
})
})

Expand Down
54 changes: 41 additions & 13 deletions zui/src/transforms/zui-to-typescript-type/utils.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,57 @@
import { camelCase, deburr } from '../../ui/utils'
import { Primitive } from '../../z'

export function escapeString(str: string) {
if (typeof str !== 'string') {
return ''
/**
* @returns a valid typescript literal type usable in `type MyType = ${x}`
*/
export function primitiveToTypscriptLiteralType(x: Primitive): string {
if (typeof x === 'symbol') {
return 'symbol' // there's no way to represent a symbol literal in a single line with typescript
}
if (typeof x === 'bigint') {
const str = x.toString()
return `${str}n`
}
return primitiveToTypescriptValue(x)
}

// Use String.raw to get the raw string with escapes preserved
const rawStr = String.raw`${str}`
/**
* @returns a valid typescript primitive value usable in `const myValue = ${x}`
*/
export function primitiveToTypescriptValue(x: Primitive): string {
if (typeof x === 'undefined') {
return 'undefined'
}
if (typeof x === 'symbol') {
if (x.description) {
return `Symbol(${primitiveToTypescriptValue(x.description)})`
}
return 'Symbol()'
}
if (typeof x === 'bigint') {
const str = x.toString()
return `BigInt(${str})`
}
return JSON.stringify(x)
}

// Determine the appropriate quote style
if (rawStr.includes('`')) {
return `"${rawStr.replace(/"/g, '\\"')}"`
} else if (rawStr.includes("'")) {
return `'${rawStr.replace(/'/g, "\\'")}'`
} else {
return `'${rawStr}'`
/**
* @returns a valid typescript value usable in `const myValue = ${x}`
*/
export function unknownToTypescriptValue(x: unknown): string {
if (typeof x === 'undefined') {
return 'undefined'
}
// will fail or not behave as expected if x contains a symbol or a bigint
return JSON.stringify(x)
}

export const toPropertyKey = (key: string) => {
if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key)) {
return key
}

return escapeString(key)
return primitiveToTypescriptValue(key)
}

const capitalize = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1)
Expand Down