From 5d5b733c0715cc8eb4b316278180676c61059db2 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Tue, 26 May 2026 13:31:59 +0200 Subject: [PATCH 1/5] fix(typegen): render schema edge cases --- src/Typegen.test.ts | 42 ++++++++++++++++++++++++++++++ src/Typegen.ts | 63 +++++++++++++++++++++++++++++++++++---------- 2 files changed, 91 insertions(+), 14 deletions(-) diff --git a/src/Typegen.test.ts b/src/Typegen.test.ts index e34640c..2e197d9 100644 --- a/src/Typegen.test.ts +++ b/src/Typegen.test.ts @@ -248,6 +248,48 @@ 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') + expect(output).toContain('flags: { required: boolean } & Record') + }) + test('mixed top-level and grouped commands', () => { const cli = Cli.create('test') cli.command('ping', { run: () => ({}) }) diff --git a/src/Typegen.ts b/src/Typegen.ts index 0903fe6..eb17b5b 100644 --- a/src/Typegen.ts +++ b/src/Typegen.ts @@ -88,28 +88,63 @@ function resolveType( case 'null': return 'null' case 'array': { + const prefixItems = schema.prefixItems as Record[] | undefined + if (prefixItems) { + const items = prefixItems.map((item) => resolveType(item, defs)) + const rest = schema.items as Record | undefined + if (rest) items.push(`...${arrayToType(resolveType(rest, defs))}`) + return `[${items.join(', ')}]` + } + const items = schema.items as Record | undefined const itemType = items ? resolveType(items, defs) : 'unknown' - return itemType.includes(' | ') ? `(${itemType})[]` : `${itemType}[]` - } - case 'object': { - const properties = schema.properties as Record> | 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})[]` : `${type}[]` +} + +function objectToType( + schema: Record, + defs: Record>, +): string { + const properties = schema.properties as Record> | undefined + const required = new Set((schema.required as string[] | 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 | boolean | undefined + + if (!additional) return object + const value = typeof additional === 'object' ? resolveType(additional, defs) : 'unknown' + const propertyNames = schema.propertyNames as Record | undefined + const record = `Record<${propertyNames ? resolveType(propertyNames, defs) : 'string'}, ${value}>` + if (entries.length === 0) return record + return `${object} & ${record}` +} + +function propertyToType( + key: string, + schema: Record, + required: Set, + defs: Record>, +): 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) { From 31392dfe7076a6216be6b2b76a299047cf33d3d9 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Tue, 26 May 2026 19:05:02 +0200 Subject: [PATCH 2/5] fix(typegen): correct record edge cases --- src/Typegen.test.ts | 62 ++++++++++++++++++++++++++++++++++++++- src/Typegen.ts | 70 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 129 insertions(+), 3 deletions(-) diff --git a/src/Typegen.test.ts b/src/Typegen.test.ts index 2e197d9..047779c 100644 --- a/src/Typegen.test.ts +++ b/src/Typegen.test.ts @@ -287,7 +287,67 @@ describe('fromCli', () => { const output = Typegen.fromCli(cli) expect(output).toContain('counts: Record') - expect(output).toContain('flags: { required: boolean } & Record') + expect(output).toContain('flags: { required: boolean } & Record') + }) + + 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) + expect(output).toContain('flags: { required: boolean } & Record') + }) + + 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)[]') + }) + + 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>') + }) + + 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('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') }) test('mixed top-level and grouped commands', () => { diff --git a/src/Typegen.ts b/src/Typegen.ts index eb17b5b..2efdaba 100644 --- a/src/Typegen.ts +++ b/src/Typegen.ts @@ -108,7 +108,7 @@ function resolveType( } function arrayToType(type: string): string { - return type.includes(' | ') ? `(${type})[]` : `${type}[]` + return type.includes(' | ') || type.includes(' & ') ? `(${type})[]` : `${type}[]` } function objectToType( @@ -126,11 +126,77 @@ function objectToType( if (!additional) return object const value = typeof additional === 'object' ? resolveType(additional, defs) : 'unknown' const propertyNames = schema.propertyNames as Record | undefined - const record = `Record<${propertyNames ? resolveType(propertyNames, defs) : 'string'}, ${value}>` + 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 | undefined, + defs: Record>, +): 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 | undefined, + required: Set, +): string { + const record = `Record<${key}, ${value}>` + if (!propertyNames?.enum) return record + + const keys = propertyNames.enum as unknown[] + if (keys.every((key) => typeof key === 'string' && required.has(key))) return record + return `Partial<${record}>` +} + +function propertyValueToType( + key: string, + schema: Record, + required: Set, + defs: Record>, +): 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 start = 0 + + for (let i = 0; i < type.length; i++) { + const char = type[i] + 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, From 262d03c8a7faa4d8e63e69109a47542d3e8b77d6 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Tue, 26 May 2026 19:12:09 +0200 Subject: [PATCH 3/5] fix(typegen): handle finite record keys --- src/Typegen.test.ts | 50 +++++++++++++++++++++++++++++++++++++++++++++ src/Typegen.ts | 23 ++++++++++++++++++--- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/src/Typegen.test.ts b/src/Typegen.test.ts index 047779c..39e4839 100644 --- a/src/Typegen.test.ts +++ b/src/Typegen.test.ts @@ -299,6 +299,8 @@ describe('fromCli', () => { }) 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') }) @@ -326,6 +328,30 @@ describe('fromCli', () => { expect(output).toContain('counts: Partial>') }) + 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>') + }) + + 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>') + }) + test('required enum records render required keys', () => { const cli = Cli.create('test').command('create', { options: z.object({ @@ -338,6 +364,30 @@ describe('fromCli', () => { 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 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('record with unknown property names falls back to string keys', () => { const cli = Cli.create('test').command('create', { options: z.object({ diff --git a/src/Typegen.ts b/src/Typegen.ts index 2efdaba..2bc0718 100644 --- a/src/Typegen.ts +++ b/src/Typegen.ts @@ -153,13 +153,25 @@ function recordToType( required: Set, ): string { const record = `Record<${key}, ${value}>` - if (!propertyNames?.enum) return record + const keys = propertyNames ? finitePropertyNames(propertyNames) : undefined + if (!keys) return record - const keys = propertyNames.enum as unknown[] if (keys.every((key) => typeof key === 'string' && required.has(key))) return record return `Partial<${record}>` } +function finitePropertyNames(schema: Record): 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[]).flatMap( + (schema) => finitePropertyNames(schema) ?? [], + ) + if (keys.length > 0) return keys + } + return undefined +} + function propertyValueToType( key: string, schema: Record, @@ -180,11 +192,16 @@ function unionTypes(types: string[]): string { 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 (char === '(' || char === '[' || char === '{' || char === '<') depth++ + 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)) From ec60e3640e2195f79b7bdd5ed13c2e0c07df450e Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Tue, 26 May 2026 19:16:30 +0200 Subject: [PATCH 4/5] fix(typegen): keep mixed record keys open --- src/Typegen.test.ts | 12 ++++++++++++ src/Typegen.ts | 6 ++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/Typegen.test.ts b/src/Typegen.test.ts index 39e4839..729b0ad 100644 --- a/src/Typegen.test.ts +++ b/src/Typegen.test.ts @@ -352,6 +352,18 @@ describe('fromCli', () => { expect(output).toContain('counts: Partial>') }) + 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({ diff --git a/src/Typegen.ts b/src/Typegen.ts index 2bc0718..4990faf 100644 --- a/src/Typegen.ts +++ b/src/Typegen.ts @@ -164,10 +164,8 @@ function finitePropertyNames(schema: Record): unknown[] | undef if ('const' in schema) return [schema.const] if (schema.enum) return schema.enum as unknown[] if (schema.anyOf) { - const keys = (schema.anyOf as Record[]).flatMap( - (schema) => finitePropertyNames(schema) ?? [], - ) - if (keys.length > 0) return keys + const keys = (schema.anyOf as Record[]).map(finitePropertyNames) + if (keys.every((key) => key)) return keys.flatMap((key) => key) } return undefined } From d06d94d86738080da8d393fdca40d1238cc3ba94 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Tue, 26 May 2026 19:23:32 +0200 Subject: [PATCH 5/5] fix(typegen): preserve numeric record keys --- src/Typegen.test.ts | 36 ++++++++++++++++++++++++++++++++++++ src/Typegen.ts | 10 +++++----- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/Typegen.test.ts b/src/Typegen.test.ts index 729b0ad..92e081e 100644 --- a/src/Typegen.test.ts +++ b/src/Typegen.test.ts @@ -340,6 +340,18 @@ describe('fromCli', () => { expect(output).toContain('counts: Partial>') }) + 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>') + }) + test('partial union records render optional keys', () => { const cli = Cli.create('test').command('create', { options: z.object({ @@ -388,6 +400,18 @@ describe('fromCli', () => { 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({ @@ -400,6 +424,18 @@ describe('fromCli', () => { 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({ diff --git a/src/Typegen.ts b/src/Typegen.ts index 4990faf..4469a07 100644 --- a/src/Typegen.ts +++ b/src/Typegen.ts @@ -116,7 +116,7 @@ function objectToType( defs: Record>, ): string { const properties = schema.properties as Record> | undefined - const required = new Set((schema.required as string[] | undefined) ?? []) + const required = new Set((schema.required as unknown[] | undefined) ?? []) const entries = Object.entries(properties ?? {}).map(([key, value]) => propertyToType(key, value, required, defs), ) @@ -150,13 +150,13 @@ function recordToType( key: string, value: string, propertyNames: Record | undefined, - required: Set, + required: Set, ): string { const record = `Record<${key}, ${value}>` const keys = propertyNames ? finitePropertyNames(propertyNames) : undefined if (!keys) return record - if (keys.every((key) => typeof key === 'string' && required.has(key))) return record + if (keys.every((key) => required.has(key))) return record return `Partial<${record}>` } @@ -173,7 +173,7 @@ function finitePropertyNames(schema: Record): unknown[] | undef function propertyValueToType( key: string, schema: Record, - required: Set, + required: Set, defs: Record>, ): string { const type = resolveType(schema, defs) @@ -215,7 +215,7 @@ function splitUnionType(type: string): string[] { function propertyToType( key: string, schema: Record, - required: Set, + required: Set, defs: Record>, ): string { const type = resolveType(schema, defs)