From cc94bb1654285605a513761a9e699984d45dfe1a Mon Sep 17 00:00:00 2001 From: benjamineckstein <13351939+benjamineckstein@users.noreply.github.com> Date: Sun, 14 Jun 2026 16:14:07 +0200 Subject: [PATCH 1/5] feat(generator)!: map int64 to number instead of bigint to fix runtime serialization JSON.stringify throws on bigint values and JSON.parse never produces bigint, making z.bigint() unworkable for standard API clients. int64 fields now map to number with an inline comment noting precision is limited to 2^53-1. Changes: - primitiveToTs(): int64 branch returns 'number /* int64, precision limited to 2^53-1 */' - primitiveToZod(): int64 branch returns 'z.number()' - primitiveTypeToZod(): remove dead 'if (base !== z.bigint())' guard so numeric constraints (min/max/multipleOf) now chain correctly onto int64 fields - Update int64 unit tests in types-unit.test.ts and zod-unit.test.ts - Regenerate committed example output (openai and generated-rq models.ts files) BREAKING CHANGE: int64 fields previously generated as bigint / z.bigint(). Consumers who relied on bigint types for int64 fields must update their code. User-owned schemas.ts files containing z.bigint() for int64 properties will get TS compile errors and must be manually updated to z.number(). Closes #292 --- .../generated-rq/adyen-checkout/models.ts | 42 +++++++++---------- .../generated-rq/adyen-legal-entity/models.ts | 2 +- examples/generated-rq/devto/models.ts | 38 ++++++++--------- examples/generated-rq/openai/models.ts | 16 +++---- examples/generated-rq/petstore-3.0/models.ts | 12 +++--- examples/generated-rq/spotify/models.ts | 6 +-- examples/generated/openai/models.ts | 16 +++---- .../src/__tests__/types-unit.test.ts | 18 ++++---- .../src/__tests__/zod-unit.test.ts | 19 +++++---- packages/openapi-zod-ts/src/plugins/types.ts | 9 ++-- packages/openapi-zod-ts/src/plugins/zod.ts | 16 ++++--- 11 files changed, 99 insertions(+), 95 deletions(-) diff --git a/examples/generated-rq/adyen-checkout/models.ts b/examples/generated-rq/adyen-checkout/models.ts index 00d5bdb..522b34b 100644 --- a/examples/generated-rq/adyen-checkout/models.ts +++ b/examples/generated-rq/adyen-checkout/models.ts @@ -365,7 +365,7 @@ export interface Agency { export interface Airline { agency?: Agency - boardingFee?: bigint + boardingFee?: number /* int64, precision limited to 2^53-1 */ code?: string computerizedReservationSystem?: string customerReferenceNumber?: string @@ -396,12 +396,12 @@ export interface AmazonPayDetails { export interface Amount { currency: string - value: bigint + value: number /* int64, precision limited to 2^53-1 */ } export interface Amounts { currency: string - values: bigint[] + values: number /* int64, precision limited to 2^53-1 */[] } export interface AncvDetails { @@ -1199,9 +1199,9 @@ export interface DokuDetails { export interface Donation { currency: string donationType: string - maxRoundupAmount?: bigint + maxRoundupAmount?: number /* int64, precision limited to 2^53-1 */ type: string - values?: bigint[] + values?: number /* int64, precision limited to 2^53-1 */[] } export interface DonationCampaign { @@ -1530,12 +1530,12 @@ export interface Item { export interface ItemDetailLine { commodityCode?: string description?: string - discountAmount?: bigint + discountAmount?: number /* int64, precision limited to 2^53-1 */ productCode?: string - quantity?: bigint - totalAmount?: bigint + quantity?: number /* int64, precision limited to 2^53-1 */ + totalAmount?: number /* int64, precision limited to 2^53-1 */ unitOfMeasure?: string - unitPrice?: bigint + unitPrice?: number /* int64, precision limited to 2^53-1 */ } export interface KlarnaDetails { @@ -1572,7 +1572,7 @@ export interface Leg { classOfTravel?: string dateOfTravel?: string /* date-time */ departureAirportCode?: string - departureTax?: bigint + departureTax?: number /* int64, precision limited to 2^53-1 */ destinationAirportCode?: string fareBasisCode?: string flightNumber?: string @@ -1582,17 +1582,17 @@ export interface Leg { export interface LevelTwoThree { customerReferenceNumber?: string destination?: Destination - dutyAmount?: bigint - freightAmount?: bigint + dutyAmount?: number /* int64, precision limited to 2^53-1 */ + freightAmount?: number /* int64, precision limited to 2^53-1 */ itemDetailLines?: ItemDetailLine[] orderDate?: string /* date */ shipFromPostalCode?: string - totalTaxAmount?: bigint + totalTaxAmount?: number /* int64, precision limited to 2^53-1 */ } export interface LineItem { - amountExcludingTax?: bigint - amountIncludingTax?: bigint + amountExcludingTax?: number /* int64, precision limited to 2^53-1 */ + amountIncludingTax?: number /* int64, precision limited to 2^53-1 */ brand?: string color?: string description?: string @@ -1602,12 +1602,12 @@ export interface LineItem { manufacturer?: string marketplaceSellerId?: string productUrl?: string - quantity?: bigint + quantity?: number /* int64, precision limited to 2^53-1 */ receiverEmail?: string size?: string sku?: string - taxAmount?: bigint - taxPercentage?: bigint + taxAmount?: number /* int64, precision limited to 2^53-1 */ + taxPercentage?: number /* int64, precision limited to 2^53-1 */ upc?: string } @@ -2550,7 +2550,7 @@ export interface PixPayByBankDetails { export interface PixPayByBankRiskSignals { confidenceScore?: ConfidenceScore - elapsedTimeSinceBoot?: bigint + elapsedTimeSinceBoot?: number /* int64, precision limited to 2^53-1 */ isRootedDevice?: boolean language?: string osVersion?: string @@ -2876,7 +2876,7 @@ export interface Split { export interface SplitAmount { currency?: string - value: bigint + value: number /* int64, precision limited to 2^53-1 */ } export interface StandalonePaymentCancelRequest { @@ -3018,7 +3018,7 @@ export interface SubMerchantInfo { } export interface Surcharge { - value: bigint + value: number /* int64, precision limited to 2^53-1 */ } export interface TaxTotal { diff --git a/examples/generated-rq/adyen-legal-entity/models.ts b/examples/generated-rq/adyen-legal-entity/models.ts index 347bf8f..51b1ad5 100644 --- a/examples/generated-rq/adyen-legal-entity/models.ts +++ b/examples/generated-rq/adyen-legal-entity/models.ts @@ -46,7 +46,7 @@ export interface Address { export interface Amount { currency?: string - value?: bigint + value?: number /* int64, precision limited to 2^53-1 */ } export interface Attachment { diff --git a/examples/generated-rq/devto/models.ts b/examples/generated-rq/devto/models.ts index 5617dc9..343c4d8 100644 --- a/examples/generated-rq/devto/models.ts +++ b/examples/generated-rq/devto/models.ts @@ -36,11 +36,11 @@ export interface ArticleIndex { export interface VideoArticle { type_of?: string - id?: bigint + id?: number /* int64, precision limited to 2^53-1 */ path?: string cloudinary_video_url?: string title?: string - user_id?: bigint + user_id?: number /* int64, precision limited to 2^53-1 */ video_duration_in_minutes?: string video_source_url?: string user?: { @@ -78,13 +78,13 @@ export interface Organization { } export interface FollowedTag { - id: bigint + id: number /* int64, precision limited to 2^53-1 */ name: string points: number } export interface Tag { - id?: bigint + id?: number /* int64, precision limited to 2^53-1 */ name?: string bg_color_hex?: string | null text_color_hex?: string | null @@ -138,7 +138,7 @@ export interface SharedOrganization { export interface User { type_of?: string - id?: bigint + id?: number /* int64, precision limited to 2^53-1 */ username?: string name?: string summary?: string | null @@ -152,7 +152,7 @@ export interface User { export interface ExtendedUser { type_of?: string - id?: bigint + id?: number /* int64, precision limited to 2^53-1 */ username?: string name?: string summary?: string | null @@ -168,7 +168,7 @@ export interface ExtendedUser { export interface MyUser { type_of?: string - id?: bigint + id?: number /* int64, precision limited to 2^53-1 */ username?: string name?: string summary?: string | null @@ -261,7 +261,7 @@ export interface SegmentUserIds { } export interface AgentSessionIndex { - id: bigint + id: number /* int64, precision limited to 2^53-1 */ slug: string title: string tool_name: string @@ -273,7 +273,7 @@ export interface AgentSessionIndex { } export interface AgentSessionShow { - id: bigint + id: number /* int64, precision limited to 2^53-1 */ slug: string title: string tool_name: string @@ -290,7 +290,7 @@ export interface AgentSessionShow { export interface PollOption { type_of: 'poll_option' - id: bigint + id: number /* int64, precision limited to 2^53-1 */ markdown: string | null processed_html: string | null position: number @@ -300,7 +300,7 @@ export interface PollOption { export interface Poll { type_of: 'poll' - id: bigint + id: number /* int64, precision limited to 2^53-1 */ prompt_markdown: string | null prompt_html: string | null poll_type_of: 'single_choice' | 'multiple_choice' | 'scale' | 'text_input' @@ -317,7 +317,7 @@ export interface Poll { export interface Survey { type_of: 'survey' - id: bigint + id: number /* int64, precision limited to 2^53-1 */ title: string slug: string survey_type_of: 'community_pulse' | 'industry' | 'fun' @@ -334,10 +334,10 @@ export type SurveyWithPolls = Survey & { export interface PollVote { type_of: 'poll_vote' - id: bigint - poll_id: bigint - poll_option_id: bigint - user_id: bigint + id: number /* int64, precision limited to 2^53-1 */ + poll_id: number /* int64, precision limited to 2^53-1 */ + poll_option_id: number /* int64, precision limited to 2^53-1 */ + user_id: number /* int64, precision limited to 2^53-1 */ user_email: string session_start: number created_at: string /* date-time */ @@ -345,9 +345,9 @@ export interface PollVote { export interface PollTextResponse { type_of: 'poll_text_response' - id: bigint - poll_id: bigint - user_id: bigint + id: number /* int64, precision limited to 2^53-1 */ + poll_id: number /* int64, precision limited to 2^53-1 */ + user_id: number /* int64, precision limited to 2^53-1 */ user_email: string text_content: string session_start: number diff --git a/examples/generated-rq/openai/models.ts b/examples/generated-rq/openai/models.ts index 9e4f92b..2e34bd2 100644 --- a/examples/generated-rq/openai/models.ts +++ b/examples/generated-rq/openai/models.ts @@ -41,7 +41,7 @@ export interface AssignedRoleDetails { predefined_role: boolean description: string | null created_at: number | null - updated_at: bigint | null + updated_at: number /* int64, precision limited to 2^53-1 */ | null created_by: string | null created_by_user_obj: Record | null metadata: Record | null @@ -682,7 +682,7 @@ export interface Batch { export interface BatchFileExpirationAfter { anchor: 'created_at' - seconds: bigint + seconds: number /* int64, precision limited to 2^53-1 */ } export interface Certificate { @@ -1288,7 +1288,7 @@ export interface CreateCompletionRequest { max_tokens?: number | null n?: number | null presence_penalty?: number | null - seed?: bigint | null + seed?: number /* int64, precision limited to 2^53-1 */ | null stop?: StopConfiguration stream?: boolean | null stream_options?: ChatCompletionStreamOptions @@ -2302,7 +2302,7 @@ export interface EvalStoredCompletionsSource { export interface FileExpirationAfter { anchor: 'created_at' - seconds: bigint + seconds: number /* int64, precision limited to 2^53-1 */ } export interface FilePath { @@ -4498,7 +4498,7 @@ export interface RealtimeConversationItemWithReference { export interface RealtimeCreateClientSecretRequest { expires_after?: { anchor?: 'created_at' - seconds?: bigint + seconds?: number /* int64, precision limited to 2^53-1 */ } session?: RealtimeSessionCreateRequestGA | RealtimeTranscriptionSessionCreateRequestGA } @@ -5491,7 +5491,7 @@ export interface RealtimeTranslationClientEventSessionUpdate { export interface RealtimeTranslationClientSecretCreateRequest { expires_after?: { anchor?: 'created_at' - seconds?: bigint + seconds?: number /* int64, precision limited to 2^53-1 */ } session: RealtimeTranslationSessionCreateRequest } @@ -6944,7 +6944,7 @@ export interface UsageAudioSpeechesResult { export interface UsageAudioTranscriptionsResult { object: 'organization.usage.audio_transcriptions.result' - seconds: bigint + seconds: number /* int64, precision limited to 2^53-1 */ num_model_requests: number project_id?: string | null user_id?: string | null @@ -8641,7 +8641,7 @@ export interface WorkflowParam { export interface ExpiresAfterParam { anchor: 'created_at' - seconds: bigint + seconds: number /* int64, precision limited to 2^53-1 */ } export interface RateLimitsParam { diff --git a/examples/generated-rq/petstore-3.0/models.ts b/examples/generated-rq/petstore-3.0/models.ts index ca3e7ce..c427a86 100644 --- a/examples/generated-rq/petstore-3.0/models.ts +++ b/examples/generated-rq/petstore-3.0/models.ts @@ -1,8 +1,8 @@ // This file is auto-generated by openapi-zod-ts - do not edit export interface Order { - id?: bigint - petId?: bigint + id?: number /* int64, precision limited to 2^53-1 */ + petId?: number /* int64, precision limited to 2^53-1 */ quantity?: number shipDate?: string /* date-time */ status?: 'placed' | 'approved' | 'delivered' @@ -10,12 +10,12 @@ export interface Order { } export interface Category { - id?: bigint + id?: number /* int64, precision limited to 2^53-1 */ name?: string } export interface User { - id?: bigint + id?: number /* int64, precision limited to 2^53-1 */ username?: string firstName?: string lastName?: string @@ -26,12 +26,12 @@ export interface User { } export interface Tag { - id?: bigint + id?: number /* int64, precision limited to 2^53-1 */ name?: string } export interface Pet { - id?: bigint + id?: number /* int64, precision limited to 2^53-1 */ name: string category?: Category photoUrls: string[] diff --git a/examples/generated-rq/spotify/models.ts b/examples/generated-rq/spotify/models.ts index 7b4d8b6..86541ba 100644 --- a/examples/generated-rq/spotify/models.ts +++ b/examples/generated-rq/spotify/models.ts @@ -70,7 +70,7 @@ export interface CurrentlyPlayingContextObject { repeat_state?: string shuffle_state?: boolean context?: ContextObject - timestamp?: bigint + timestamp?: number /* int64, precision limited to 2^53-1 */ progress_ms?: number is_playing?: boolean item?: TrackObject | EpisodeObject @@ -128,7 +128,7 @@ export interface AudioAnalysisObject { platform?: string detailed_status?: string status_code?: number - timestamp?: bigint + timestamp?: number /* int64, precision limited to 2^53-1 */ analysis_time?: number input_process?: string } @@ -672,7 +672,7 @@ export interface ExplicitContentSettingsObject { export interface CurrentlyPlayingObject { context?: ContextObject - timestamp?: bigint + timestamp?: number /* int64, precision limited to 2^53-1 */ progress_ms?: number is_playing?: boolean item?: TrackObject | EpisodeObject diff --git a/examples/generated/openai/models.ts b/examples/generated/openai/models.ts index 9e4f92b..2e34bd2 100644 --- a/examples/generated/openai/models.ts +++ b/examples/generated/openai/models.ts @@ -41,7 +41,7 @@ export interface AssignedRoleDetails { predefined_role: boolean description: string | null created_at: number | null - updated_at: bigint | null + updated_at: number /* int64, precision limited to 2^53-1 */ | null created_by: string | null created_by_user_obj: Record | null metadata: Record | null @@ -682,7 +682,7 @@ export interface Batch { export interface BatchFileExpirationAfter { anchor: 'created_at' - seconds: bigint + seconds: number /* int64, precision limited to 2^53-1 */ } export interface Certificate { @@ -1288,7 +1288,7 @@ export interface CreateCompletionRequest { max_tokens?: number | null n?: number | null presence_penalty?: number | null - seed?: bigint | null + seed?: number /* int64, precision limited to 2^53-1 */ | null stop?: StopConfiguration stream?: boolean | null stream_options?: ChatCompletionStreamOptions @@ -2302,7 +2302,7 @@ export interface EvalStoredCompletionsSource { export interface FileExpirationAfter { anchor: 'created_at' - seconds: bigint + seconds: number /* int64, precision limited to 2^53-1 */ } export interface FilePath { @@ -4498,7 +4498,7 @@ export interface RealtimeConversationItemWithReference { export interface RealtimeCreateClientSecretRequest { expires_after?: { anchor?: 'created_at' - seconds?: bigint + seconds?: number /* int64, precision limited to 2^53-1 */ } session?: RealtimeSessionCreateRequestGA | RealtimeTranscriptionSessionCreateRequestGA } @@ -5491,7 +5491,7 @@ export interface RealtimeTranslationClientEventSessionUpdate { export interface RealtimeTranslationClientSecretCreateRequest { expires_after?: { anchor?: 'created_at' - seconds?: bigint + seconds?: number /* int64, precision limited to 2^53-1 */ } session: RealtimeTranslationSessionCreateRequest } @@ -6944,7 +6944,7 @@ export interface UsageAudioSpeechesResult { export interface UsageAudioTranscriptionsResult { object: 'organization.usage.audio_transcriptions.result' - seconds: bigint + seconds: number /* int64, precision limited to 2^53-1 */ num_model_requests: number project_id?: string | null user_id?: string | null @@ -8641,7 +8641,7 @@ export interface WorkflowParam { export interface ExpiresAfterParam { anchor: 'created_at' - seconds: bigint + seconds: number /* int64, precision limited to 2^53-1 */ } export interface RateLimitsParam { diff --git a/packages/openapi-zod-ts/src/__tests__/types-unit.test.ts b/packages/openapi-zod-ts/src/__tests__/types-unit.test.ts index e7c13cb..44cf810 100644 --- a/packages/openapi-zod-ts/src/__tests__/types-unit.test.ts +++ b/packages/openapi-zod-ts/src/__tests__/types-unit.test.ts @@ -522,11 +522,11 @@ describe('coverage: generateTypes schema-enhanced mode with no schemas (types.ts }) }) -describe('int64 format -> bigint in types', () => { - it('integer with format int64 -> bigint TS type', () => { +describe('int64 format -> number in types', () => { + it('integer with format int64 -> number TS type with precision comment', () => { const out = genSingle('Id', { type: 'integer', format: 'int64' }) - expect(out).toContain('bigint') - expect(out).not.toContain('number') + expect(out).toContain('number /* int64, precision limited to 2^53-1 */') + expect(out).not.toContain('bigint') }) it('integer with no format -> number', () => { @@ -535,19 +535,21 @@ describe('int64 format -> bigint in types', () => { expect(out).not.toContain('bigint') }) - it('int64 as object property', () => { + it('int64 as object property -> number with precision comment', () => { const out = genSingle('Obj', { type: 'object', required: ['id'], properties: { id: { type: 'integer', format: 'int64' } }, }) - expect(out).toContain('id: bigint') + expect(out).toContain('id: number /* int64, precision limited to 2^53-1 */') + expect(out).not.toContain('bigint') }) - it('nullable int64 array type: [integer, null] with int64 format', () => { + it('nullable int64 array type: [integer, null] with int64 format -> number with precision comment | null', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const out = genSingle('Id', { type: ['integer', 'null'], format: 'int64' } as any) - expect(out).toContain('bigint | null') + expect(out).toContain('number /* int64, precision limited to 2^53-1 */ | null') + expect(out).not.toContain('bigint') }) }) diff --git a/packages/openapi-zod-ts/src/__tests__/zod-unit.test.ts b/packages/openapi-zod-ts/src/__tests__/zod-unit.test.ts index 4596d04..517a226 100644 --- a/packages/openapi-zod-ts/src/__tests__/zod-unit.test.ts +++ b/packages/openapi-zod-ts/src/__tests__/zod-unit.test.ts @@ -606,14 +606,15 @@ describe('coverage: multi-type array with null member (zod.ts line 118 TRUE bran }) }) -describe('int64 format -> bigint', () => { - it('integer with format int64 -> z.bigint()', () => { +describe('int64 format -> z.number()', () => { + it('integer with format int64 -> z.number()', () => { const out = genSingle('Id', { type: 'integer', format: 'int64' }) // Check the schema declaration line specifically to avoid matching header comments - expect(out).toContain('IdSchema = z.bigint()') + expect(out).toContain('IdSchema = z.number()') + expect(out).not.toContain('z.bigint()') }) - it('integer with format int32 -> z.number() (only int64 gets bigint)', () => { + it('integer with format int32 -> z.number()', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const out = genSingle('Id', { type: 'integer', format: 'int32' } as any) expect(out).toContain('IdSchema = z.number()') @@ -626,19 +627,21 @@ describe('int64 format -> bigint', () => { expect(out).not.toContain('z.bigint()') }) - it('int64 as object property', () => { + it('int64 as object property -> z.number()', () => { const out = genSingle('Obj', { type: 'object', required: ['id'], properties: { id: { type: 'integer', format: 'int64' } }, }) - expect(out).toContain('id: z.bigint()') + expect(out).toContain('id: z.number()') + expect(out).not.toContain('z.bigint()') }) - it('nullable int64 array type: [integer, null] with int64 format', () => { + it('nullable int64 array type: [integer, null] with int64 format -> z.number().nullable()', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const out = genSingle('Id', { type: ['integer', 'null'], format: 'int64' } as any) - expect(out).toContain('z.bigint().nullable()') + expect(out).toContain('z.number().nullable()') + expect(out).not.toContain('z.bigint()') }) }) diff --git a/packages/openapi-zod-ts/src/plugins/types.ts b/packages/openapi-zod-ts/src/plugins/types.ts index 99e7ce8..a4fdbdf 100644 --- a/packages/openapi-zod-ts/src/plugins/types.ts +++ b/packages/openapi-zod-ts/src/plugins/types.ts @@ -201,8 +201,10 @@ function schemaToTypeString( /** * Map an OpenAPI primitive type to a TypeScript type. - * For integer with format int64, returns bigint instead of number - * to preserve precision for 64-bit IDs. + * For integer with format int64, returns number with an inline comment noting precision + * is limited to 2^53-1 (JS Number.MAX_SAFE_INTEGER). BigInt is avoided because + * JSON.stringify throws on bigint values and JSON.parse never produces bigint, making + * bigint unworkable for standard API client serialization. */ function primitiveToTs(type: string, format?: string): string { switch (type) { @@ -211,8 +213,7 @@ function primitiveToTs(type: string, format?: string): string { case 'number': return 'number' case 'integer': - // int64 requires bigint for precision-safe 64-bit IDs (JS number cannot represent >2^53) - return format === 'int64' ? 'bigint' : 'number' + return format === 'int64' ? 'number /* int64, precision limited to 2^53-1 */' : 'number' case 'boolean': return 'boolean' case 'null': diff --git a/packages/openapi-zod-ts/src/plugins/zod.ts b/packages/openapi-zod-ts/src/plugins/zod.ts index a454969..2fcc13f 100644 --- a/packages/openapi-zod-ts/src/plugins/zod.ts +++ b/packages/openapi-zod-ts/src/plugins/zod.ts @@ -81,8 +81,9 @@ function serializeLiteral(value: unknown): string { /** * Return the Zod v4 base expression for a primitive type. - * For integer with format int64, returns z.bigint() instead of z.number() - * to preserve precision for 64-bit IDs. + * For integer with format int64, returns z.number() instead of z.bigint() because + * JSON.stringify throws on bigint values and JSON.parse never produces bigint. Precision + * is limited to 2^53-1 (JS Number.MAX_SAFE_INTEGER) for int64 fields. */ function primitiveToZod(type: string, format?: string): string { switch (type) { @@ -91,8 +92,7 @@ function primitiveToZod(type: string, format?: string): string { case 'number': return 'z.number()' case 'integer': - // int64 requires bigint for precision-safe 64-bit IDs (JS number cannot represent >2^53) - return format === 'int64' ? 'z.bigint()' : 'z.number()' + return format === 'int64' ? 'z.number()' : 'z.number()' case 'boolean': return 'z.boolean()' case 'null': @@ -325,8 +325,8 @@ function objectSchemaToZod(schema: SchemaObject): string { /** * Handle scalar primitive types: string, integer, number, boolean, null. - * Applies format-based specialisation (stringSchemaExpr, int64 bigint) and - * numeric/string constraints before returning the Zod expression. + * Applies format-based specialisation (stringSchemaExpr, numeric constraints) and + * string/numeric constraints before returning the Zod expression. */ function primitiveTypeToZod(schema: SchemaObject): string { const type = schema.type as string @@ -335,9 +335,7 @@ function primitiveTypeToZod(schema: SchemaObject): string { base = stringSchemaExpr(schema) } else if (type === 'integer') { base = primitiveToZod('integer', schema.format as string | undefined) - if (base !== 'z.bigint()') { - base = applyNumberConstraints(base, schema) - } + base = applyNumberConstraints(base, schema) } else if (type === 'number') { base = primitiveToZod('number') base = applyNumberConstraints(base, schema) From 4128fa350d25c2c026fc62cc133ed3d416b90746 Mon Sep 17 00:00:00 2001 From: benjamineckstein <13351939+benjamineckstein@users.noreply.github.com> Date: Sun, 14 Jun 2026 16:15:20 +0200 Subject: [PATCH 2/5] fix(generator): type inline additionalProperties responses as Record inlineSchemaToTs() in client.ts previously returned 'Record' for any object type unconditionally. When an operation response schema has additionalProperties set to a schema object, the generated return type now recurses into the value schema and produces Record. The named-schema path in types.ts already handled this correctly via schemaToTypeString. This fix brings the inline response path in line with it. Examples: - additionalProperties: {type: integer, format: int32} -> Record - additionalProperties: {type: string} -> Record - additionalProperties: true -> Record (unchanged) - no additionalProperties -> Record (unchanged) Closes #294 --- .../src/__tests__/client.test.ts | 114 ++++++++++++++++++ packages/openapi-zod-ts/src/plugins/client.ts | 18 ++- 2 files changed, 131 insertions(+), 1 deletion(-) diff --git a/packages/openapi-zod-ts/src/__tests__/client.test.ts b/packages/openapi-zod-ts/src/__tests__/client.test.ts index b563116..8886e31 100644 --- a/packages/openapi-zod-ts/src/__tests__/client.test.ts +++ b/packages/openapi-zod-ts/src/__tests__/client.test.ts @@ -643,6 +643,120 @@ describe('coverage: deprecated + throws together (lines 321-329, 387-394)', () = }) }) +describe('inline additionalProperties response typing (#294)', () => { + it('operation response with additionalProperties schema object -> Record', () => { + const spec: OpenAPIV3_1.Document = { + openapi: '3.1.0', + info: { title: 'T', version: '1' }, + paths: { + '/metrics': { + get: { + operationId: 'getMetrics', + responses: { + '200': { + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: { type: 'integer', format: 'int32' }, + }, + }, + }, + }, + }, + }, + }, + }, + } + const out = generateClient(spec as OpenAPIV3_1.Document).content + expect(out).toContain('Promise>') + expect(out).not.toContain('Record') + }) + + it('operation response with additionalProperties: true -> Record', () => { + const spec: OpenAPIV3_1.Document = { + openapi: '3.1.0', + info: { title: 'T', version: '1' }, + paths: { + '/data': { + get: { + operationId: 'getData', + responses: { + '200': { + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: true, + }, + }, + }, + }, + }, + }, + }, + }, + } + const out = generateClient(spec as OpenAPIV3_1.Document).content + expect(out).toContain('Promise>') + }) + + it('operation response with object and no additionalProperties -> Record', () => { + const spec: OpenAPIV3_1.Document = { + openapi: '3.1.0', + info: { title: 'T', version: '1' }, + paths: { + '/plain': { + get: { + operationId: 'getPlain', + responses: { + '200': { + content: { + 'application/json': { + schema: { + type: 'object', + }, + }, + }, + }, + }, + }, + }, + }, + } + const out = generateClient(spec as OpenAPIV3_1.Document).content + expect(out).toContain('Promise>') + }) + + it('operation response with additionalProperties string schema -> Record', () => { + const spec: OpenAPIV3_1.Document = { + openapi: '3.1.0', + info: { title: 'T', version: '1' }, + paths: { + '/labels': { + get: { + operationId: 'getLabels', + responses: { + '200': { + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, + } + const out = generateClient(spec as OpenAPIV3_1.Document).content + expect(out).toContain('Promise>') + }) +}) + describe('coverage: inlineSchemaToTs edge cases (lines 28, 31-33, 37)', () => { it('response with nullable array type → string | null (line 28: Array.isArray(s.type) branch)', () => { // inlineSchemaToTs handles type: ['string', 'null'] → 'string | null' diff --git a/packages/openapi-zod-ts/src/plugins/client.ts b/packages/openapi-zod-ts/src/plugins/client.ts index 3c2ac79..a405a5a 100644 --- a/packages/openapi-zod-ts/src/plugins/client.ts +++ b/packages/openapi-zod-ts/src/plugins/client.ts @@ -73,7 +73,23 @@ function inlineSchemaToTs( if (items !== undefined) return `${inlineSchemaToTs(items, spec, visited)}[]` return 'unknown[]' } - if (s.type === 'object') return 'Record' + if (s.type === 'object') { + // When additionalProperties is a schema object (not true/false/absent), recurse to get + // the typed value: Record instead of the generic Record. + if ( + s.additionalProperties !== undefined && + s.additionalProperties !== true && + s.additionalProperties !== false + ) { + const valueType = inlineSchemaToTs( + s.additionalProperties as SchemaObject | ReferenceObject, + spec, + visited + ) + return `Record` + } + return 'Record' + } if (s.type !== undefined) return primitiveToTs(s.type as string) return 'unknown' } From 06f89c1a14a36ac7ee88a2aaf8321bb776db03ed Mon Sep 17 00:00:00 2001 From: benjamineckstein <13351939+benjamineckstein@users.noreply.github.com> Date: Sun, 14 Jun 2026 16:21:01 +0200 Subject: [PATCH 3/5] feat(generator): add error_body_type config for typed schema-less error bodies Adds optional error_body_type and error_body_type_import fields to Config. When set, schema-less error responses in the generated client are cast to the specified type instead of unknown. Special value 'laravel' emits a built-in LaravelValidationError type ({ message: string; errors: Record }) alongside ApiError and casts the error body to it. No import is needed for this built-in. Any other non-empty string is used as a type name directly. When error_body_type_import is also set, the generator emits an import type statement at the top of the generated client. Without an import path the type is treated as ambient/global (a deliberate choice documented in the JSDoc). Changes: - Config interface: error_body_type and error_body_type_import optional fields - parseConfig(): validates both fields as non-empty strings when present - ClientOptions: errorBodyType and errorBodyTypeImport optional fields - HelperFeatures: errorBodyType field threaded to emitErrorCheckAndReturn() - emitErrorCheckAndReturn(): accepts optional errorBodyType, emits typed cast - generateClient(): emits LaravelValidationError type after ApiError when errorBodyType is 'laravel'; emits import type when custom type + import set - generator.ts: passes config.error_body_type and error_body_type_import into ClientOptions for both initial and schema-enhanced generation passes Closes #293 --- .../src/__tests__/client.test.ts | 67 ++++++++++++++++++ .../src/__tests__/config.test.ts | 42 +++++++++++ packages/openapi-zod-ts/src/config.ts | 28 ++++++++ packages/openapi-zod-ts/src/generator.ts | 18 ++++- packages/openapi-zod-ts/src/plugins/client.ts | 69 +++++++++++++++++-- 5 files changed, 217 insertions(+), 7 deletions(-) diff --git a/packages/openapi-zod-ts/src/__tests__/client.test.ts b/packages/openapi-zod-ts/src/__tests__/client.test.ts index 8886e31..8ee4929 100644 --- a/packages/openapi-zod-ts/src/__tests__/client.test.ts +++ b/packages/openapi-zod-ts/src/__tests__/client.test.ts @@ -251,6 +251,73 @@ describe('inline response schemas', () => { }) }) +describe('error_body_type (#293): typed schema-less error bodies', () => { + const minimalSpec: OpenAPIV3_1.Document = { + openapi: '3.1.0', + info: { title: 'T', version: '1' }, + paths: { + '/items': { + get: { + operationId: 'listItems', + responses: { + '200': { content: { 'application/json': { schema: { type: 'string' } } } }, + }, + }, + }, + }, + } + + it('without errorBodyType: error body cast uses no type annotation', () => { + const out = generateClient(minimalSpec).content + expect(out).toContain('await res.json().catch(() => null)') + expect(out).not.toContain(' as ') + }) + + it('errorBodyType laravel: LaravelValidationError type is emitted after ApiError', () => { + const out = generateClient(minimalSpec, { errorBodyType: 'laravel' }).content + expect(out).toContain('export type LaravelValidationError = {') + expect(out).toContain('message: string') + expect(out).toContain('errors: Record') + }) + + it('errorBodyType laravel: error body is cast to LaravelValidationError', () => { + const out = generateClient(minimalSpec, { errorBodyType: 'laravel' }).content + expect(out).toContain('await res.json().catch(() => null) as LaravelValidationError') + }) + + it('errorBodyType laravel: no import is emitted for LaravelValidationError', () => { + const out = generateClient(minimalSpec, { errorBodyType: 'laravel' }).content + expect(out).not.toMatch(/import type \{.*LaravelValidationError/) + }) + + it('errorBodyType custom + importPath: import type is emitted at top', () => { + const out = generateClient(minimalSpec, { + errorBodyType: 'ApiErrorBody', + errorBodyTypeImport: './types/errors', + }).content + expect(out).toContain("import type { ApiErrorBody } from './types/errors'") + }) + + it('errorBodyType custom + importPath: error body is cast to custom type', () => { + const out = generateClient(minimalSpec, { + errorBodyType: 'ApiErrorBody', + errorBodyTypeImport: './types/errors', + }).content + expect(out).toContain('await res.json().catch(() => null) as ApiErrorBody') + }) + + it('errorBodyType custom without importPath: cast emitted, no import', () => { + const out = generateClient(minimalSpec, { errorBodyType: 'GlobalErrorType' }).content + expect(out).toContain('await res.json().catch(() => null) as GlobalErrorType') + expect(out).not.toMatch(/import type \{.*GlobalErrorType/) + }) + + it('errorBodyType custom: LaravelValidationError type NOT emitted', () => { + const out = generateClient(minimalSpec, { errorBodyType: 'ApiErrorBody' }).content + expect(out).not.toContain('LaravelValidationError') + }) +}) + describe('generateClient with empty paths', () => { it('handles spec with no paths gracefully', async () => { const spec = await parseSpec(join(__dirname, '../__fixtures__/specs/task-manager.json')) diff --git a/packages/openapi-zod-ts/src/__tests__/config.test.ts b/packages/openapi-zod-ts/src/__tests__/config.test.ts index bbde62d..fe015ab 100644 --- a/packages/openapi-zod-ts/src/__tests__/config.test.ts +++ b/packages/openapi-zod-ts/src/__tests__/config.test.ts @@ -91,6 +91,48 @@ describe('loadConfig', () => { await expect(loadConfig(tmpDir)).rejects.toThrow('"server_client" must be a boolean') }) + it('loads error_body_type field', async () => { + writeConfig({ input_openapi: 'openapi.json', output: 'src/api', error_body_type: 'laravel' }) + const config = await loadConfig(tmpDir) + expect(config.error_body_type).toBe('laravel') + }) + + it('loads error_body_type with error_body_type_import', async () => { + writeConfig({ + input_openapi: 'openapi.json', + output: 'src/api', + error_body_type: 'ApiErrorBody', + error_body_type_import: './types/errors', + }) + const config = await loadConfig(tmpDir) + expect(config.error_body_type).toBe('ApiErrorBody') + expect(config.error_body_type_import).toBe('./types/errors') + }) + + it('error_body_type and error_body_type_import are undefined when absent', async () => { + writeConfig({ input_openapi: 'openapi.json', output: 'src/api' }) + const config = await loadConfig(tmpDir) + expect(config.error_body_type).toBeUndefined() + expect(config.error_body_type_import).toBeUndefined() + }) + + it('throws when error_body_type is an empty string', async () => { + writeConfig({ input_openapi: 'openapi.json', output: 'src/api', error_body_type: '' }) + await expect(loadConfig(tmpDir)).rejects.toThrow('"error_body_type" must be a non-empty string') + }) + + it('throws when error_body_type_import is an empty string', async () => { + writeConfig({ + input_openapi: 'openapi.json', + output: 'src/api', + error_body_type: 'MyError', + error_body_type_import: '', + }) + await expect(loadConfig(tmpDir)).rejects.toThrow( + '"error_body_type_import" must be a non-empty string' + ) + }) + it('ignores unknown config fields', async () => { writeConfig({ input_openapi: 'openapi.json', output: 'src/api', unknown_field: 'ignored' }) const config = await loadConfig(tmpDir) diff --git a/packages/openapi-zod-ts/src/config.ts b/packages/openapi-zod-ts/src/config.ts index d76bed5..b43a6d0 100644 --- a/packages/openapi-zod-ts/src/config.ts +++ b/packages/openapi-zod-ts/src/config.ts @@ -19,6 +19,20 @@ export interface Config { baseUrl?: string /** When true, generates server.ts with a createServerClient() factory for Next.js RSC (default: false) */ server_client?: boolean + /** + * When set, schema-less error bodies in the generated client are cast to this type. + * Use 'laravel' to emit the built-in LaravelValidationError type alongside ApiError. + * Any other non-empty string is used as a type name; pair with error_body_type_import + * to emit an import, or omit it to treat the type as ambient/global. + */ + error_body_type?: string + /** + * Module path for the custom error body type. Only used when error_body_type is set to a + * value other than 'laravel'. When provided, the generator emits + * `import type { TypeName } from 'importPath'` at the top of the generated client. + * Ignored when error_body_type is absent or 'laravel'. + */ + error_body_type_import?: string } /** @@ -57,11 +71,25 @@ function parseConfig(raw: Record, base: import('./config-core.j if (raw['server_client'] !== undefined && typeof raw['server_client'] !== 'boolean') { throw new Error('"server_client" must be a boolean') } + if ( + raw['error_body_type'] !== undefined && + (typeof raw['error_body_type'] !== 'string' || !raw['error_body_type']) + ) { + throw new Error('"error_body_type" must be a non-empty string when present') + } + if ( + raw['error_body_type_import'] !== undefined && + (typeof raw['error_body_type_import'] !== 'string' || !raw['error_body_type_import']) + ) { + throw new Error('"error_body_type_import" must be a non-empty string when present') + } return { ...base, input_schema: raw['input_schema'] as string | undefined, baseUrl: typeof raw['baseUrl'] === 'string' ? raw['baseUrl'] : undefined, server_client: raw['server_client'] as boolean | undefined, + error_body_type: raw['error_body_type'] as string | undefined, + error_body_type_import: raw['error_body_type_import'] as string | undefined, } } diff --git a/packages/openapi-zod-ts/src/generator.ts b/packages/openapi-zod-ts/src/generator.ts index ecda10b..f104b66 100644 --- a/packages/openapi-zod-ts/src/generator.ts +++ b/packages/openapi-zod-ts/src/generator.ts @@ -80,7 +80,16 @@ async function generateOne( cookieAuth ? { defaultCredentials: 'include', authSchemes } : { authSchemes } ) ) - generatedFiles.push(generateClient(spec, undefined, writableVariantMap)) + generatedFiles.push( + generateClient( + spec, + { + errorBodyType: config.error_body_type, + errorBodyTypeImport: config.error_body_type_import, + }, + writableVariantMap + ) + ) generatedFiles.push(generateIndexBarrel()) console.log(`${prefix}Writing output to: ${outputDir}`) @@ -162,7 +171,12 @@ async function generateZodIntegration( ) const enhancedClient = generateClient( spec, - { schemaNames: exportedSchemas, schemaImportPath }, + { + schemaNames: exportedSchemas, + schemaImportPath, + errorBodyType: config.error_body_type, + errorBodyTypeImport: config.error_body_type_import, + }, writableVariantMap ) const enhancedTypesPath = join(outputDir, enhancedTypes.filename) diff --git a/packages/openapi-zod-ts/src/plugins/client.ts b/packages/openapi-zod-ts/src/plugins/client.ts index a405a5a..d9729b7 100644 --- a/packages/openapi-zod-ts/src/plugins/client.ts +++ b/packages/openapi-zod-ts/src/plugins/client.ts @@ -839,6 +839,12 @@ interface HelperFeatures { hasMultipart: boolean /** Any endpoint uses application/x-www-form-urlencoded. Emits bodyEncoding branch in `_request`. */ hasFormUrlencoded: boolean + /** + * When set, schema-less error bodies are cast to this type name in the generated error handler. + * Use 'laravel' for the built-in LaravelValidationError type or any other string for an + * ambient/imported type (see ClientOptions.errorBodyTypeImport for the import path). + */ + errorBodyType?: string } /** @@ -1006,10 +1012,22 @@ function emitAuthHeaderSpreads( } } -/** Emits the shared error-check + return block at the end of a helper function. */ -function emitErrorCheckAndReturn(lines: string[]): void { +/** + * Emits the shared error-check + return block at the end of a helper function. + * When errorBodyType is provided, the error body is cast to that type so callers + * get a typed body instead of unknown. The special value 'laravel' uses the built-in + * LaravelValidationError type emitted alongside ApiError; any other value is used as-is + * (caller is responsible for ensuring the type is in scope). + */ +function emitErrorCheckAndReturn(lines: string[], errorBodyType?: string): void { lines.push(` if (!res.ok) {`) - lines.push(` const err = new ApiError(res.status, await res.json().catch(() => null))`) + if (errorBodyType !== undefined) { + lines.push( + ` const err = new ApiError(res.status, await res.json().catch(() => null) as ${errorBodyType})` + ) + } else { + lines.push(` const err = new ApiError(res.status, await res.json().catch(() => null))`) + } lines.push(` onError?.(err)`) lines.push(` throw err`) lines.push(` }`) @@ -1132,7 +1150,7 @@ function emitRequestFunction( lines.push(` }`) emitOnRequestBlock(lines) emitSignalAndFetch(lines) - emitErrorCheckAndReturn(lines) + emitErrorCheckAndReturn(lines, features.errorBodyType) } /** Emits the _requestForm function into lines (up to and including the closing brace). */ @@ -1173,7 +1191,7 @@ function emitRequestFormFunction( lines.push(` }`) emitOnRequestBlock(lines) emitSignalAndFetch(lines) - emitErrorCheckAndReturn(lines) + emitErrorCheckAndReturn(lines, features.errorBodyType) } /** @@ -1453,6 +1471,20 @@ function generateFunctionCode( export interface ClientOptions { schemaNames?: Set schemaImportPath?: string + /** + * When set, schema-less error bodies are cast to this type in the generated client. + * Use 'laravel' to emit the built-in LaravelValidationError type + * ({ message: string; errors: Record }) alongside ApiError. + * Any other string is used as-is; pair it with errorBodyTypeImport to emit an import, + * or leave errorBodyTypeImport unset to treat it as an ambient/global type. + */ + errorBodyType?: string + /** + * When errorBodyType is set to a custom type name (not 'laravel'), provide the module + * path here and the generator will emit `import type { TypeName } from 'importPath'` + * at the top of the generated client. Ignored when errorBodyType is 'laravel' or absent. + */ + errorBodyTypeImport?: string } /** Built-in TypeScript types that must NOT be imported from ./models */ @@ -1653,6 +1685,18 @@ export function generateClient( lines.push(`import { ${sortedSchemas.join(', ')} } from '${options.schemaImportPath}'`) } + // When a custom error body type with an import path is configured, emit the import. + // The 'laravel' value uses a built-in type emitted inline and needs no import. + if ( + options?.errorBodyType !== undefined && + options.errorBodyType !== 'laravel' && + options.errorBodyTypeImport !== undefined + ) { + lines.push( + `import type { ${options.errorBodyType} } from '${options.errorBodyTypeImport}'` + ) + } + lines.push('') // ApiError class — generic so callers can type-narrow caught errors. @@ -1669,16 +1713,31 @@ export function generateClient( lines.push(` }`) lines.push(`}`) + // When error_body_type is 'laravel', emit the built-in LaravelValidationError type + // immediately after ApiError so it is available for the typed error body cast. + if (options?.errorBodyType === 'laravel') { + lines.push(``) + lines.push(`export type LaravelValidationError = {`) + lines.push(` message: string`) + lines.push(` errors: Record`) + lines.push(`}`) + } + // Shared private request helpers — emitted once, called by every endpoint function. // Code inside the helpers is feature-conditional: only emit auth/credentials/extraHeaders // when the spec actually declares those features. if (hasAnyEndpoints) { + // Resolve the actual TS type name for the error body cast. + // 'laravel' is a config shorthand for the built-in LaravelValidationError type. + const resolvedErrorBodyType = + options?.errorBodyType === 'laravel' ? 'LaravelValidationError' : options?.errorBodyType const helperFeatures: HelperFeatures = { authSchemes: detectAuthSchemes(spec), hasCookieAuth: hasCookieAuth(spec), hasHeaderParams: hasHeaderParamEndpoints, hasMultipart: hasMultipartEndpoints, hasFormUrlencoded: hasFormUrlencodedEndpoints, + errorBodyType: resolvedErrorBodyType, } lines.push('') lines.push(generateRequestHelpers(helperFeatures)) From 68379a7da0ef35523fb7ebf2cfe5d7803aaaccc4 Mon Sep 17 00:00:00 2001 From: benjamineckstein <13351939+benjamineckstein@users.noreply.github.com> Date: Sun, 14 Jun 2026 16:29:18 +0200 Subject: [PATCH 4/5] refactor(generator): reduce complexity in parseConfig and inlineSchemaToTs Extract validateErrorBodyConfig() from parseConfig() to hold the error_body_type/error_body_type_import validation branches, bringing parseConfig below the cyclomatic threshold. Extract inlineObjectSchemaToTs() from inlineSchemaToTs() to hold the additionalProperties object branch, bringing inlineSchemaToTs below the cyclomatic and cognitive thresholds. Pure extraction, no behavior change. --- packages/openapi-zod-ts/src/config.ts | 25 ++++++----- packages/openapi-zod-ts/src/plugins/client.ts | 43 +++++++++++-------- 2 files changed, 41 insertions(+), 27 deletions(-) diff --git a/packages/openapi-zod-ts/src/config.ts b/packages/openapi-zod-ts/src/config.ts index b43a6d0..24e7d03 100644 --- a/packages/openapi-zod-ts/src/config.ts +++ b/packages/openapi-zod-ts/src/config.ts @@ -61,16 +61,8 @@ export function defineProjects(configs: Config[]): { projects: Config[] } { return { projects: configs } } -function parseConfig(raw: Record, base: import('./config-core.js').BaseConfig): Config { - if ( - raw['input_schema'] !== undefined && - (typeof raw['input_schema'] !== 'string' || !raw['input_schema']) - ) { - throw new Error('"input_schema" must be a non-empty string path to your Zod schema file') - } - if (raw['server_client'] !== undefined && typeof raw['server_client'] !== 'boolean') { - throw new Error('"server_client" must be a boolean') - } +/** Validate the error_body_type / error_body_type_import optional fields. */ +function validateErrorBodyConfig(raw: Record): void { if ( raw['error_body_type'] !== undefined && (typeof raw['error_body_type'] !== 'string' || !raw['error_body_type']) @@ -83,6 +75,19 @@ function parseConfig(raw: Record, base: import('./config-core.j ) { throw new Error('"error_body_type_import" must be a non-empty string when present') } +} + +function parseConfig(raw: Record, base: import('./config-core.js').BaseConfig): Config { + if ( + raw['input_schema'] !== undefined && + (typeof raw['input_schema'] !== 'string' || !raw['input_schema']) + ) { + throw new Error('"input_schema" must be a non-empty string path to your Zod schema file') + } + if (raw['server_client'] !== undefined && typeof raw['server_client'] !== 'boolean') { + throw new Error('"server_client" must be a boolean') + } + validateErrorBodyConfig(raw) return { ...base, input_schema: raw['input_schema'] as string | undefined, diff --git a/packages/openapi-zod-ts/src/plugins/client.ts b/packages/openapi-zod-ts/src/plugins/client.ts index d9729b7..ae676d7 100644 --- a/packages/openapi-zod-ts/src/plugins/client.ts +++ b/packages/openapi-zod-ts/src/plugins/client.ts @@ -51,6 +51,31 @@ function resolveDeepRefToTs( return result } +/** + * Resolve the TypeScript Record type for an inline object schema. + * When additionalProperties is a schema object, recurse to produce Record. + * Otherwise fall back to Record. + */ +function inlineObjectSchemaToTs( + s: SchemaObject, + spec?: OpenAPIV3_1.Document, + visited?: Set +): string { + if ( + s.additionalProperties !== undefined && + s.additionalProperties !== true && + s.additionalProperties !== false + ) { + const valueType = inlineSchemaToTs( + s.additionalProperties as SchemaObject | ReferenceObject, + spec, + visited + ) + return `Record` + } + return 'Record' +} + function inlineSchemaToTs( schema: SchemaObject | ReferenceObject, spec?: OpenAPIV3_1.Document, @@ -73,23 +98,7 @@ function inlineSchemaToTs( if (items !== undefined) return `${inlineSchemaToTs(items, spec, visited)}[]` return 'unknown[]' } - if (s.type === 'object') { - // When additionalProperties is a schema object (not true/false/absent), recurse to get - // the typed value: Record instead of the generic Record. - if ( - s.additionalProperties !== undefined && - s.additionalProperties !== true && - s.additionalProperties !== false - ) { - const valueType = inlineSchemaToTs( - s.additionalProperties as SchemaObject | ReferenceObject, - spec, - visited - ) - return `Record` - } - return 'Record' - } + if (s.type === 'object') return inlineObjectSchemaToTs(s, spec, visited) if (s.type !== undefined) return primitiveToTs(s.type as string) return 'unknown' } From 6aa666fd778b1751b504ca687edc0ce5ed05cf40 Mon Sep 17 00:00:00 2001 From: benjamineckstein <13351939+benjamineckstein@users.noreply.github.com> Date: Sun, 14 Jun 2026 18:09:06 +0200 Subject: [PATCH 5/5] fix(examples): regenerate showcase output for int64/additionalProperties changes petstore-3.0 getInventory() return type updated from Record to Record in both examples/generated and examples/generated-rq, fixing the Showcase CI drift check. --- examples/generated-rq/petstore-3.0/client.ts | 2 +- examples/generated/petstore-3.0/client.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/generated-rq/petstore-3.0/client.ts b/examples/generated-rq/petstore-3.0/client.ts index 8319eb0..a960764 100644 --- a/examples/generated-rq/petstore-3.0/client.ts +++ b/examples/generated-rq/petstore-3.0/client.ts @@ -182,7 +182,7 @@ export async function uploadFile( export async function getInventory( config?: Partial -): Promise> { +): Promise> { const res = await _request('GET', '/store/inventory', {}, config) return res.json() } diff --git a/examples/generated/petstore-3.0/client.ts b/examples/generated/petstore-3.0/client.ts index e1fb681..972fe90 100644 --- a/examples/generated/petstore-3.0/client.ts +++ b/examples/generated/petstore-3.0/client.ts @@ -186,7 +186,7 @@ export async function uploadFile( export async function getInventory( config?: Partial -): Promise> { +): Promise> { const res = await _request('GET', '/store/inventory', {}, config) return res.json() }