Skip to content

fix(typegen): render schema edge cases#11

Draft
0xpolarzero wants to merge 5 commits into
mainfrom
typegen-schema-rendering-correctness
Draft

fix(typegen): render schema edge cases#11
0xpolarzero wants to merge 5 commits into
mainfrom
typegen-schema-rendering-correctness

Conversation

@0xpolarzero
Copy link
Copy Markdown
Owner

@0xpolarzero 0xpolarzero commented May 26, 2026

Overview

Fix Typegen.fromCli() output for schema shapes that were previously rendered as invalid, unsound, or too lossy TypeScript.

This is only declaration generation. It does not change runtime parsing, validation, routing, or command execution.

Issue

The previous renderer handled simple object properties and plain arrays, but missed several JSON Schema shapes that Zod emits.

Optional properties were missing | undefined:

z.object({ verbose: z.boolean().optional() })

// before
{ verbose?: boolean }

// after
{ verbose?: boolean | undefined }

With exactOptionalPropertyTypes, an optional property that can be assigned undefined needs the explicit | undefined.

Some object keys could produce invalid TypeScript:

z.object({ 'dry-run': z.boolean() })

// before
{ dry-run: boolean }

// after
{ "dry-run": boolean }

Tuple schemas were rendered as regular arrays:

z.tuple([z.number(), z.number()])
// before: unknown[]
// after:  [number, number]

z.tuple([z.string()]).rest(z.number())
// before: number[]
// after:  [string, ...number[]]

Record-like schemas also lost important information:

z.record(z.string(), z.number())
// before: {}
// after:  Record<string, number>

z.partialRecord(z.enum(['open', 'closed']), z.number())
// before: {}
// after:  Partial<Record<"open" | "closed", number>>

propertyNames restricts which keys are allowed. It does not make every allowed key required, so partial records need Partial<Record<K, V>>.

Catchall objects need care because TypeScript Record<string, T> applies to known keys too:

z.object({ required: z.boolean() }).catchall(z.string())

// before
{ required: boolean }

// after
{ required: boolean } & Record<string, string | boolean>

The old output dropped the catchall entirely. A naive { required: boolean } & Record<string, string> fix would reject valid values like { required: true, extra: "ok" }, because Record<string, string> also applies to the known required key. The generated record value is widened with the declared property value types so known properties remain assignable. This is a deliberate TypeScript approximation: it preserves a usable indexable shape, but it is not as exact as JSON Schema's "additional keys only" rule.

Arrays with complex item types also needed parentheses so [] applies to the full item type:

z.array(z.object({ id: z.number() }).catchall(z.string()))

// naive output while adding catchall records
{ id: number } & Record<string, string | number>[]

// after
({ id: number } & Record<string, string | number>)[]

Tests failing before fix

src/Typegen.test.ts checks optional properties:

expect(output).toContain('verbose?: boolean | undefined')

src/Typegen.test.ts checks invalid property keys:

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

src/Typegen.test.ts checks tuples and tuple rest items:

expect(output).toContain('point: [number, number]')
expect(output).toContain('range: [string, ...number[]]')

src/Typegen.test.ts checks string records and catchalls:

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

src/Typegen.test.ts checks array item parentheses for object intersections:

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

src/Typegen.test.ts checks partial enum records:

expect(output).toContain('counts: Partial<Record<"open" | "closed", number>>')

src/Typegen.test.ts, src/Typegen.test.ts, and src/Typegen.test.ts check partial literal, numeric literal, and literal-union records:

expect(output).toContain('counts: Partial<Record<"open", number>>')
expect(output).toContain('counts: Partial<Record<1, number>>')
expect(output).toContain('counts: Partial<Record<"open" | "closed", number>>')

src/Typegen.test.ts checks mixed finite/open key records:

expect(output).toContain('counts: Record<"open" | string, number>')

src/Typegen.test.ts checks required enum records:

expect(output).toContain('counts: Record<"open" | "closed", number>')

src/Typegen.test.ts, src/Typegen.test.ts, src/Typegen.test.ts, and src/Typegen.test.ts check required literal, numeric literal, literal-union, and numeric-union records:

expect(output).toContain('counts: Record<"open", number>')
expect(output).toContain('counts: Record<1, number>')
expect(output).toContain('counts: Record<"open" | "closed", number>')
expect(output).toContain('counts: Record<1 | 2, number>')

src/Typegen.test.ts checks unknown property-name schemas fall back to valid record keys:

expect(output).toContain('counts: Record<string, number>')

src/e2e.test.ts updates the full generated declaration snapshot so real CLI optional args render with | undefined.

Regression guards

These tests cover behavior that could regress while fixing the schema renderer:

  • quoted keys still work inside nested objects;
  • tuple rest items preserve ...T[] syntax;
  • regular string records render as Record<string, V>;
  • finite-key records are required only when Zod marks every finite key required;
  • partial finite-key records do not require every allowed key;
  • mixed finite/open key records stay open instead of being collapsed to partial finite records;
  • catchall records remain assignable when fixed properties have incompatible value types, with the known TypeScript precision tradeoff described above;
  • record keys never render as invalid Record<unknown, V>;
  • arrays parenthesize complex item types before appending [].

Fix

schemaToType() now delegates object rendering to a shared objectToType() helper instead of duplicating only the top-level properties case.

The renderer now:

  • quotes object property names that are not valid TypeScript identifiers;
  • emits ?: T | undefined for optional object properties;
  • maps JSON Schema prefixItems to tuple syntax;
  • preserves tuple rest items as ...T[];
  • maps string records to Record<string, V>;
  • maps required finite-key records to Record<K, V>;
  • maps optional finite-key records to Partial<Record<K, V>>;
  • falls back to string for unknown record key schemas;
  • widens catchall record values with declared property value types so known keys remain assignable;
  • parenthesizes union and intersection item types before rendering arrays.

Copy link
Copy Markdown
Owner Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant