Skip to content
Closed
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
200 changes: 200 additions & 0 deletions src/Typegen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,206 @@ describe('fromCli', () => {
expect(output).toContain('output: string')
})

test('invalid property keys are quoted', () => {
const cli = Cli.create('test').command('create', {
options: z.object({
'dry-run': z.boolean(),
nested: z.object({ 'output-file': z.string().optional() }),
}),
run: () => ({}),
})

const output = Typegen.fromCli(cli)
expect(output).toContain('"dry-run": boolean')
expect(output).toContain('nested: { "output-file"?: string | undefined }')
})

test('tuple schemas render as TypeScript tuples', () => {
const cli = Cli.create('test').command('create', {
options: z.object({
point: z.tuple([z.number(), z.number()]),
range: z.tuple([z.string()]).rest(z.number()),
}),
run: () => ({}),
})

const output = Typegen.fromCli(cli)
expect(output).toContain('point: [number, number]')
expect(output).toContain('range: [string, ...number[]]')
})

test('record and catchall schemas render indexable types', () => {
const cli = Cli.create('test').command('create', {
options: z.object({
counts: z.record(z.string(), z.number()),
flags: z.object({ required: z.boolean() }).catchall(z.string()),
}),
run: () => ({}),
})

const output = Typegen.fromCli(cli)
expect(output).toContain('counts: Record<string, number>')
expect(output).toContain('flags: { required: boolean } & Record<string, string | boolean>')
})

test('catchall schemas widen record values for incompatible fixed properties', () => {
const cli = Cli.create('test').command('create', {
options: z.object({
flags: z.object({ required: z.boolean() }).catchall(z.string()),
}),
run: () => ({}),
})

const output = Typegen.fromCli(cli)
// TypeScript cannot express "only unknown extra keys" with a plain index signature.
// Widening keeps declared properties assignable while still preserving an indexable shape.
expect(output).toContain('flags: { required: boolean } & Record<string, string | boolean>')
})

test('array item intersections are parenthesized', () => {
const cli = Cli.create('test').command('create', {
options: z.object({
items: z.array(z.object({ id: z.number() }).catchall(z.string())),
}),
run: () => ({}),
})

const output = Typegen.fromCli(cli)
expect(output).toContain('items: ({ id: number } & Record<string, string | number>)[]')
})

test('partial enum records render optional keys', () => {
const cli = Cli.create('test').command('create', {
options: z.object({
counts: z.partialRecord(z.enum(['open', 'closed']), z.number()),
}),
run: () => ({}),
})

const output = Typegen.fromCli(cli)
expect(output).toContain('counts: Partial<Record<"open" | "closed", number>>')
})

test('partial literal records render optional keys', () => {
const cli = Cli.create('test').command('create', {
options: z.object({
counts: z.partialRecord(z.literal('open'), z.number()),
}),
run: () => ({}),
})

const output = Typegen.fromCli(cli)
expect(output).toContain('counts: Partial<Record<"open", number>>')
})

test('partial numeric literal records render optional keys', () => {
const cli = Cli.create('test').command('create', {
options: z.object({
counts: z.partialRecord(z.literal(1), z.number()),
}),
run: () => ({}),
})

const output = Typegen.fromCli(cli)
expect(output).toContain('counts: Partial<Record<1, number>>')
})

test('partial union records render optional keys', () => {
const cli = Cli.create('test').command('create', {
options: z.object({
counts: z.partialRecord(z.union([z.literal('open'), z.literal('closed')]), z.number()),
}),
run: () => ({}),
})

const output = Typegen.fromCli(cli)
expect(output).toContain('counts: Partial<Record<"open" | "closed", number>>')
})

test('mixed finite and open key records render open keys', () => {
const cli = Cli.create('test').command('create', {
options: z.object({
counts: z.partialRecord(z.union([z.literal('open'), z.string()]), z.number()),
}),
run: () => ({}),
})

const output = Typegen.fromCli(cli)
expect(output).toContain('counts: Record<"open" | string, number>')
})

test('required enum records render required keys', () => {
const cli = Cli.create('test').command('create', {
options: z.object({
counts: z.record(z.enum(['open', 'closed']), z.number()),
}),
run: () => ({}),
})

const output = Typegen.fromCli(cli)
expect(output).toContain('counts: Record<"open" | "closed", number>')
})

test('required literal records render required keys', () => {
const cli = Cli.create('test').command('create', {
options: z.object({
counts: z.record(z.literal('open'), z.number()),
}),
run: () => ({}),
})

const output = Typegen.fromCli(cli)
expect(output).toContain('counts: Record<"open", number>')
})

test('required numeric literal records render required keys', () => {
const cli = Cli.create('test').command('create', {
options: z.object({
counts: z.record(z.literal(1), z.number()),
}),
run: () => ({}),
})

const output = Typegen.fromCli(cli)
expect(output).toContain('counts: Record<1, number>')
})

test('required union records render required keys', () => {
const cli = Cli.create('test').command('create', {
options: z.object({
counts: z.record(z.union([z.literal('open'), z.literal('closed')]), z.number()),
}),
run: () => ({}),
})

const output = Typegen.fromCli(cli)
expect(output).toContain('counts: Record<"open" | "closed", number>')
})

test('required numeric union records render required keys', () => {
const cli = Cli.create('test').command('create', {
options: z.object({
counts: z.record(z.union([z.literal(1), z.literal(2)]), z.number()),
}),
run: () => ({}),
})

const output = Typegen.fromCli(cli)
expect(output).toContain('counts: Record<1 | 2, number>')
})

test('record with unknown property names falls back to string keys', () => {
const cli = Cli.create('test').command('create', {
options: z.object({
counts: z.record(z.any(), z.number()),
}),
run: () => ({}),
})

const output = Typegen.fromCli(cli)
expect(output).toContain('counts: Record<string, number>')
})

test('mixed top-level and grouped commands', () => {
const cli = Cli.create('test')
cli.command('ping', { run: () => ({}) })
Expand Down
144 changes: 130 additions & 14 deletions src/Typegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,28 +88,144 @@ function resolveType(
case 'null':
return 'null'
case 'array': {
const prefixItems = schema.prefixItems as Record<string, unknown>[] | undefined
if (prefixItems) {
const items = prefixItems.map((item) => resolveType(item, defs))
const rest = schema.items as Record<string, unknown> | undefined
if (rest) items.push(`...${arrayToType(resolveType(rest, defs))}`)
return `[${items.join(', ')}]`
}

const items = schema.items as Record<string, unknown> | undefined
const itemType = items ? resolveType(items, defs) : 'unknown'
return itemType.includes(' | ') ? `(${itemType})[]` : `${itemType}[]`
}
case 'object': {
const properties = schema.properties as Record<string, Record<string, unknown>> | undefined
if (!properties || Object.keys(properties).length === 0) return '{}'
const required = new Set((schema.required as string[] | undefined) ?? [])
const entries = Object.entries(properties).map(([key, value]) => {
const type = resolveType(value, defs)
if (required.has(key)) return `${propertyKey(key)}: ${type}`
return `${propertyKey(key)}?: ${type} | undefined`
})
return `{ ${entries.join('; ')} }`
return arrayToType(itemType)
}
case 'object':
return objectToType(schema, defs)
default:
return 'unknown'
}
}

function propertyKey(key: string) {
return /^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key)
function arrayToType(type: string): string {
return type.includes(' | ') || type.includes(' & ') ? `(${type})[]` : `${type}[]`
}

function objectToType(
schema: Record<string, unknown>,
defs: Record<string, Record<string, unknown>>,
): string {
const properties = schema.properties as Record<string, Record<string, unknown>> | undefined
const required = new Set((schema.required as unknown[] | undefined) ?? [])
const entries = Object.entries(properties ?? {}).map(([key, value]) =>
propertyToType(key, value, required, defs),
)
const object = entries.length > 0 ? `{ ${entries.join('; ')} }` : '{}'
const additional = schema.additionalProperties as Record<string, unknown> | boolean | undefined

if (!additional) return object
const value = typeof additional === 'object' ? resolveType(additional, defs) : 'unknown'
const propertyNames = schema.propertyNames as Record<string, unknown> | undefined
const key = recordKeyToType(propertyNames, defs)
const propertyValues = Object.entries(properties ?? {}).map(([key, value]) =>
propertyValueToType(key, value, required, defs),
)
const recordValue = unionTypes([value, ...propertyValues])
const record = recordToType(key, recordValue, propertyNames, required)
if (entries.length === 0) return record
return `${object} & ${record}`
}

function recordKeyToType(
schema: Record<string, unknown> | undefined,
defs: Record<string, Record<string, unknown>>,
): string {
if (!schema) return 'string'
const type = resolveType(schema, defs)
if (type === 'unknown') return 'string'
return type
}

function recordToType(
key: string,
value: string,
propertyNames: Record<string, unknown> | undefined,
required: Set<unknown>,
): string {
const record = `Record<${key}, ${value}>`
const keys = propertyNames ? finitePropertyNames(propertyNames) : undefined
if (!keys) return record

if (keys.every((key) => required.has(key))) return record
return `Partial<${record}>`
}

function finitePropertyNames(schema: Record<string, unknown>): unknown[] | undefined {
if ('const' in schema) return [schema.const]
if (schema.enum) return schema.enum as unknown[]
if (schema.anyOf) {
const keys = (schema.anyOf as Record<string, unknown>[]).map(finitePropertyNames)
if (keys.every((key) => key)) return keys.flatMap((key) => key)
}
return undefined
}

function propertyValueToType(
key: string,
schema: Record<string, unknown>,
required: Set<unknown>,
defs: Record<string, Record<string, unknown>>,
): string {
const type = resolveType(schema, defs)
if (required.has(key)) return type
return unionTypes([type, 'undefined'])
}

function unionTypes(types: string[]): string {
const entries = types.flatMap(splitUnionType)
if (entries.includes('unknown')) return 'unknown'
return [...new Set(entries)].join(' | ')
}

function splitUnionType(type: string): string[] {
const parts: string[] = []
let depth = 0
let quote = ''
let start = 0

for (let i = 0; i < type.length; i++) {
const char = type[i]
if (quote) {
if (char === '\\') i++
else if (char === quote) quote = ''
} else if (char === '"' || char === "'") quote = char
else if (char === '(' || char === '[' || char === '{' || char === '<') depth++
else if (char === ')' || char === ']' || char === '}' || char === '>') depth--
else if (depth === 0 && type.slice(i, i + 3) === ' | ') {
parts.push(type.slice(start, i))
start = i + 3
i += 2
}
}

parts.push(type.slice(start))
return parts
}

function propertyToType(
key: string,
schema: Record<string, unknown>,
required: Set<unknown>,
defs: Record<string, Record<string, unknown>>,
): string {
const type = resolveType(schema, defs)
if (required.has(key)) return `${propertyKey(key)}: ${type}`
return `${propertyKey(key)}?: ${type} | undefined`
}

function propertyKey(key: string): string {
if (/^[$A-Z_a-z][$\w]*$/.test(key)) return key
return JSON.stringify(key)
}

function isStream(command: Cli.CommandDefinition<any, any, any, any, any, any>) {
Expand Down