diff --git a/.changepacks/changepack_log_7xvBpIGiuVeAOseoe2QmT.json b/.changepacks/changepack_log_7xvBpIGiuVeAOseoe2QmT.json new file mode 100644 index 0000000..efaf732 --- /dev/null +++ b/.changepacks/changepack_log_7xvBpIGiuVeAOseoe2QmT.json @@ -0,0 +1 @@ +{"changes":{"packages/generator/package.json":"Patch"},"note":"Support OpenAPI 3.1 nullable union","date":"2026-08-24T16:35:40.436478400Z"} \ No newline at end of file diff --git a/packages/generator/src/__tests__/index.test.ts b/packages/generator/src/__tests__/index.test.ts index ff6cb11..cde9a88 100644 --- a/packages/generator/src/__tests__/index.test.ts +++ b/packages/generator/src/__tests__/index.test.ts @@ -34,6 +34,9 @@ test('index.ts exports', () => { normalizeServerName: expect.any(Function), isErrorStatusCode: expect.any(Function), isNullableSchema: expect.any(Function), + isNullTypeSchema: expect.any(Function), + splitNullableUnion: expect.any(Function), + normalizeNullableUnion: expect.any(Function), getPrimaryType: expect.any(Function), collectSchemaNames: expect.any(Function), }) diff --git a/packages/generator/src/__tests__/nullable-union.test.ts b/packages/generator/src/__tests__/nullable-union.test.ts new file mode 100644 index 0000000..fcac036 --- /dev/null +++ b/packages/generator/src/__tests__/nullable-union.test.ts @@ -0,0 +1,389 @@ +import { describe, expect, test } from 'bun:test' +import type { OpenAPIV3_1 } from 'openapi-types' +import { generateInterface } from '../generate-interface' +import { + generateZodSchemas, + generateZodTypeDeclarations, +} from '../generate-zod' +import { normalizeNullableUnion, splitNullableUnion } from '../openapi-utils' + +// ============================================================================= +// Helpers +// ============================================================================= + +const REF = { $ref: '#/components/schemas/Item' } +const NULL_TYPE = { type: 'null' } +const INLINE_OBJECT = { + type: 'object', + properties: { a: { type: 'string' } }, +} + +/** + * Build a document that exercises `schema` in both a request body position + * (component reference shortcut) and a response property position (inline + * type generation). + */ +const createSchemas = ( + schema: unknown, +): Record => ({ + 'openapi.json': { + openapi: '3.1.0', + info: { title: 'Test API', version: '1.0.0' }, + paths: { + '/items': { + post: { + operationId: 'createItem', + requestBody: { content: { 'application/json': { schema } } }, + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { type: 'object', properties: { item: schema } }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Item: { type: 'object', properties: { id: { type: 'string' } } }, + }, + }, + } as unknown as OpenAPIV3_1.Document, +}) + +/** + * Build a document that exercises `schema` as a property of a component + * schema, which is the only position zod schemas are emitted for. + */ +const createComponentSchemas = ( + schema: unknown, +): Record => ({ + 'openapi.json': { + openapi: '3.1.0', + info: { title: 'Test API', version: '1.0.0' }, + paths: { + '/items': { + get: { + operationId: 'getItem', + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Wrapper' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Wrapper: { type: 'object', properties: { item: schema } }, + Item: { type: 'object', properties: { id: { type: 'string' } } }, + }, + }, + } as unknown as OpenAPIV3_1.Document, +}) + +/** + * An OpenAPI 3.1 nullable union must generate exactly what the equivalent + * OpenAPI 3.0 `nullable: true` schema generates, on all three paths. + */ +const expectParity = (openapi31: unknown, openapi30: unknown): void => { + for (const build of [createSchemas, createComponentSchemas]) { + expect(generateInterface(build(openapi31))).toBe( + generateInterface(build(openapi30)), + ) + expect(generateZodSchemas(build(openapi31))).toBe( + generateZodSchemas(build(openapi30)), + ) + expect(generateZodTypeDeclarations(build(openapi31))).toBe( + generateZodTypeDeclarations(build(openapi30)), + ) + } +} + +// ============================================================================= +// OpenAPI 3.1 nullable notation: anyOf / oneOf with { type: 'null' } +// ============================================================================= + +describe('nullable $ref union (anyOf: [$ref, { type: "null" }])', () => { + test('matches OpenAPI 3.0 { $ref, nullable: true }', () => { + expectParity({ anyOf: [REF, NULL_TYPE] }, { ...REF, nullable: true }) + }) + + test('matches OpenAPI 3.0 with oneOf notation', () => { + expectParity({ oneOf: [REF, NULL_TYPE] }, { ...REF, nullable: true }) + }) + + test('matches OpenAPI 3.0 with the null member listed first', () => { + expectParity({ anyOf: [NULL_TYPE, REF] }, { ...REF, nullable: true }) + }) + + test('keeps the component reference instead of inlining it', () => { + const result = generateInterface(createSchemas({ anyOf: [REF, NULL_TYPE] })) + + expect(result).toContain( + "body: DevupObject<'request', 'openapi.json'>['Item']", + ) + expect(result).toContain( + "item?: DevupObject<'response', 'openapi.json'>['Item']", + ) + expect(result).not.toContain('unknown)') + }) + + test('keeps the zod request path mapping', () => { + const result = generateZodSchemas( + createSchemas({ anyOf: [REF, NULL_TYPE] }), + ) + + expect(result).toContain('createItem: openapi_json_request_Item') + expect(result).toContain("'/items': openapi_json_request_Item") + }) + + test('keeps the zod lazy reference instead of a union', () => { + const result = generateZodSchemas( + createComponentSchemas({ anyOf: [REF, NULL_TYPE] }), + ) + + expect(result).toContain('z.lazy(() => _Item)') + expect(result).not.toContain('z.union([') + }) + + test('keeps the zod type declaration unwrappable', () => { + const result = generateZodTypeDeclarations( + createComponentSchemas({ anyOf: [REF, NULL_TYPE] }), + ) + + expect(result).toContain('z.ZodLazy') + expect(result).not.toContain('z.ZodUnion<') + }) +}) + +describe('nullable inline union (anyOf: [schema, { type: "null" }])', () => { + test('object member matches OpenAPI 3.0 nullable object', () => { + expectParity( + { anyOf: [INLINE_OBJECT, NULL_TYPE] }, + { ...INLINE_OBJECT, nullable: true }, + ) + }) + + test('object member matches OpenAPI 3.0 with oneOf notation', () => { + expectParity( + { oneOf: [INLINE_OBJECT, NULL_TYPE] }, + { ...INLINE_OBJECT, nullable: true }, + ) + }) + + test('primitive member matches OpenAPI 3.0 nullable primitive', () => { + expectParity( + { anyOf: [{ type: 'string' }, NULL_TYPE] }, + { type: 'string', nullable: true }, + ) + }) + + test('generates a nullable type rather than a union with unknown', () => { + const result = generateInterface( + createSchemas({ anyOf: [{ type: 'string' }, NULL_TYPE] }), + ) + + expect(result).toContain('item?: string | null') + expect(result).not.toContain('unknown') + }) + + test('generates .nullable() rather than z.union in zod', () => { + const result = generateZodSchemas( + createComponentSchemas({ anyOf: [{ type: 'string' }, NULL_TYPE] }), + ) + + expect(result).toContain('z.string().nullable()') + expect(result).not.toContain('z.union([') + }) +}) + +// ============================================================================= +// Genuine unions must not regress +// ============================================================================= + +describe('genuine unions', () => { + const UNION = { anyOf: [{ type: 'string' }, { type: 'number' }] } + + test('stays a union in the generated interface', () => { + expect(generateInterface(createSchemas(UNION))).toContain( + 'item?: (string | number)', + ) + }) + + test('stays z.union in the generated zod schema', () => { + expect(generateZodSchemas(createComponentSchemas(UNION))).toContain( + 'z.union([z.string(), z.number()])', + ) + }) + + test('stays z.ZodUnion in the generated zod type', () => { + expect( + generateZodTypeDeclarations(createComponentSchemas(UNION)), + ).toContain('z.ZodUnion<[z.ZodString, z.ZodNumber]>') + }) + + test('union with a null member stays a union and becomes nullable', () => { + const nullableUnion = { + anyOf: [{ type: 'string' }, { type: 'number' }, NULL_TYPE], + } + + expect(generateInterface(createSchemas(nullableUnion))).toContain( + 'item?: (string | number | null)', + ) + expect(generateZodSchemas(createComponentSchemas(nullableUnion))).toContain( + 'z.union([z.string(), z.number()]).nullable()', + ) + expect( + generateZodTypeDeclarations(createComponentSchemas(nullableUnion)), + ).toContain('z.ZodNullable>') + }) + + test('union with a null member matches OpenAPI 3.0 nullable union', () => { + expectParity( + { anyOf: [{ type: 'string' }, { type: 'number' }, NULL_TYPE] }, + { anyOf: [{ type: 'string' }, { type: 'number' }], nullable: true }, + ) + }) +}) + +// ============================================================================= +// OpenAPI 3.0 input must keep working +// ============================================================================= + +describe('OpenAPI 3.0 nullable input', () => { + test('nullable primitive still generates a nullable type', () => { + const nullableString = { type: 'string', nullable: true } + + expect(generateInterface(createSchemas(nullableString))).toContain( + 'item?: string | null', + ) + expect( + generateZodSchemas(createComponentSchemas(nullableString)), + ).toContain('z.string().nullable()') + }) + + test('nullable $ref still generates the component reference', () => { + expect( + generateInterface(createSchemas({ ...REF, nullable: true })), + ).toContain("body: DevupObject<'request', 'openapi.json'>['Item']") + }) +}) + +// ============================================================================= +// splitNullableUnion / normalizeNullableUnion +// ============================================================================= + +describe('splitNullableUnion', () => { + test('removes { type: "null" } members and reports nullability', () => { + expect( + splitNullableUnion([REF, NULL_TYPE] as OpenAPIV3_1.SchemaObject[]), + ).toEqual({ + members: [REF], + nullable: true, + }) + }) + + test('reports a type array of only null as a null member', () => { + expect( + splitNullableUnion([ + REF, + { type: ['null'] }, + ] as OpenAPIV3_1.SchemaObject[]), + ).toEqual({ members: [REF], nullable: true }) + }) + + test('leaves genuine unions untouched', () => { + const members = [ + { type: 'string' }, + { type: 'number' }, + ] as OpenAPIV3_1.SchemaObject[] + + expect(splitNullableUnion(members)).toEqual({ members, nullable: false }) + }) + + test('does not treat a nullable type array member as a null member', () => { + const members = [ + { type: ['string', 'null'] }, + ] as unknown as OpenAPIV3_1.SchemaObject[] + + expect(splitNullableUnion(members)).toEqual({ members, nullable: false }) + }) + + test('does not treat a $ref member as a null member', () => { + expect(splitNullableUnion([REF] as OpenAPIV3_1.SchemaObject[])).toEqual({ + members: [REF], + nullable: false, + }) + }) +}) + +describe('normalizeNullableUnion', () => { + test('collapses a nullable $ref union to the OpenAPI 3.0 shape', () => { + expect( + normalizeNullableUnion({ + anyOf: [REF, NULL_TYPE], + } as OpenAPIV3_1.SchemaObject), + ).toEqual({ ...REF, nullable: true } as OpenAPIV3_1.SchemaObject) + }) + + test('collapses a nullable oneOf union to the OpenAPI 3.0 shape', () => { + expect( + normalizeNullableUnion({ + oneOf: [INLINE_OBJECT, NULL_TYPE], + } as OpenAPIV3_1.SchemaObject), + ).toEqual({ ...INLINE_OBJECT, nullable: true } as OpenAPIV3_1.SchemaObject) + }) + + test('keeps sibling keywords of the collapsed union', () => { + expect( + normalizeNullableUnion({ + anyOf: [{ type: 'string' }, NULL_TYPE], + description: 'nickname', + default: null, + } as unknown as OpenAPIV3_1.SchemaObject), + ).toEqual({ + type: 'string', + description: 'nickname', + default: null, + nullable: true, + } as unknown as OpenAPIV3_1.SchemaObject) + }) + + test('returns the same schema for a genuine union', () => { + const schema = { + anyOf: [{ type: 'string' }, { type: 'number' }], + } as OpenAPIV3_1.SchemaObject + + expect(normalizeNullableUnion(schema)).toBe(schema) + }) + + test('returns the same schema for a union that keeps two members', () => { + const schema = { + anyOf: [{ type: 'string' }, { type: 'number' }, NULL_TYPE], + } as OpenAPIV3_1.SchemaObject + + expect(normalizeNullableUnion(schema)).toBe(schema) + }) + + test('returns the same schema when there is no union', () => { + const schema = { type: 'string' } as OpenAPIV3_1.SchemaObject + + expect(normalizeNullableUnion(schema)).toBe(schema) + }) + + test('returns the same schema for a reference object', () => { + const schema = REF as OpenAPIV3_1.ReferenceObject + + expect(normalizeNullableUnion(schema)).toBe(schema) + }) +}) diff --git a/packages/generator/src/generate-interface.ts b/packages/generator/src/generate-interface.ts index dc493c3..51c9d09 100644 --- a/packages/generator/src/generate-interface.ts +++ b/packages/generator/src/generate-interface.ts @@ -15,6 +15,7 @@ import { extractSchemaNameFromRef, getRequestBodyContent, isErrorStatusCode, + normalizeNullableUnion, normalizeServerName, resolveRef, } from './openapi-utils' @@ -61,6 +62,7 @@ function extractContentType( return undefined } + const contentSchema = normalizeNullableUnion(jsonContent.schema) const contextLabel = componentType === 'response' ? 'Response' : 'Error' const responseDefaultNonNullable = options?.responseDefaultNonNullable ?? true @@ -84,8 +86,8 @@ function extractContentType( } // Check if schema is a direct reference to components.schemas - if ('$ref' in jsonContent.schema) { - const schemaName = extractSchemaNameFromRef(jsonContent.schema.$ref) + if ('$ref' in contentSchema) { + const schemaName = extractSchemaNameFromRef(contentSchema.$ref) if ( schemaName && schema.components?.schemas?.[schemaName] && @@ -93,11 +95,11 @@ function extractContentType( ) { return `DevupObject<'${componentType}', '${serverName}'>['${schemaName}']` } - return extractInlineType(jsonContent.schema) + return extractInlineType(contentSchema) } // Check if it's an array with items referencing a component schema - const schemaObj = jsonContent.schema as OpenAPIV3_1.SchemaObject + const schemaObj = contentSchema as OpenAPIV3_1.SchemaObject if ( schemaObj.type === 'array' && schemaObj.items && @@ -111,11 +113,11 @@ function extractContentType( ) { return `Array['${schemaName}']>` } - return extractInlineType(jsonContent.schema) + return extractInlineType(contentSchema) } // Extract schema type (inline schema) - return extractInlineType(jsonContent.schema) + return extractInlineType(contentSchema) } /** @@ -317,11 +319,10 @@ function generateSchemaInterface( const content = operation.requestBody.content const bodyContent = getRequestBodyContent(content) if (bodyContent && 'schema' in bodyContent && bodyContent.schema) { + const bodySchema = normalizeNullableUnion(bodyContent.schema) // Check if schema is a direct reference to components.schemas - if ('$ref' in bodyContent.schema) { - const schemaName = extractSchemaNameFromRef( - bodyContent.schema.$ref, - ) + if ('$ref' in bodySchema) { + const schemaName = extractSchemaNameFromRef(bodySchema.$ref) // Check if schema exists in components.schemas and is used in request body if ( schemaName && @@ -341,7 +342,7 @@ function generateSchemaInterface( } } else { // Check for raw multipart: multipart/form-data with empty/generic object schema - const schemaObj = bodyContent.schema as OpenAPIV3_1.SchemaObject + const schemaObj = bodySchema as OpenAPIV3_1.SchemaObject const isMultipart = content?.['multipart/form-data'] !== undefined && !content?.['application/json'] && diff --git a/packages/generator/src/generate-schema.ts b/packages/generator/src/generate-schema.ts index 2c90bf4..83a0669 100644 --- a/packages/generator/src/generate-schema.ts +++ b/packages/generator/src/generate-schema.ts @@ -4,7 +4,9 @@ import { getPrimaryType, getRequestBodyContent, isNullableSchema, + normalizeNullableUnion, resolveRef, + splitNullableUnion, } from './openapi-utils' import { wrapInterfaceKeyGuard } from './wrap-interface-key-guard' @@ -201,17 +203,32 @@ export function getTypeFromSchema( } if (schemaObj.anyOf || schemaObj.oneOf) { - const types = (schemaObj.anyOf || schemaObj.oneOf || []).map((s) => - getTypeFromSchema(s, document, { + // `anyOf: [S, { type: 'null' }]` is OpenAPI 3.1's nullable notation, not a + // union: collapsing keeps a `$ref` member's component identity. + const normalized = normalizeNullableUnion(schemaObj) + if (normalized !== schemaObj) { + return getTypeFromSchema(normalized, document, { ...options, propertyName: undefined, - }), + }) + } + + const { members, nullable: hasNullMember } = splitNullableUnion( + schemaObj.anyOf || schemaObj.oneOf || [], ) + const unionTypes = members.map((s) => + formatTypeValue( + getTypeFromSchema(s, document, { + ...options, + propertyName: undefined, + }).type, + ), + ) + if (hasNullMember || isNullableSchema(schemaObj)) { + unionTypes.push('null') + } return { - type: - types.length > 0 - ? `(${types.map((t) => formatTypeValue(t.type)).join(' | ')})` - : 'unknown', + type: unionTypes.length > 0 ? `(${unionTypes.join(' | ')})` : 'unknown', default: schemaObj.default, } } diff --git a/packages/generator/src/generate-zod.ts b/packages/generator/src/generate-zod.ts index 59babd1..8b19d27 100644 --- a/packages/generator/src/generate-zod.ts +++ b/packages/generator/src/generate-zod.ts @@ -8,8 +8,10 @@ import { getPrimaryType, isErrorStatusCode, isNullableSchema, + normalizeNullableUnion, normalizeServerName, resolveRef, + splitNullableUnion, } from './openapi-utils' import { wrapInterfaceKeyGuard } from './wrap-interface-key-guard' @@ -44,8 +46,8 @@ function schemaToZod( const schemaObj = schema as OpenAPIV3_1.SchemaObject - const wrapNullable = (zodStr: string): string => { - if (isNullableSchema(schemaObj)) { + const wrapNullable = (zodStr: string, forceNullable = false): string => { + if (forceNullable || isNullableSchema(schemaObj)) { return `${zodStr}.nullable()` } return zodStr @@ -65,12 +67,21 @@ function schemaToZod( // Handle oneOf/anyOf (union) if (schemaObj.oneOf || schemaObj.anyOf) { - const schemas = (schemaObj.oneOf || schemaObj.anyOf || []).map((s) => + const normalized = normalizeNullableUnion(schemaObj) + if (normalized !== schemaObj) { + return schemaToZod(normalized, document, schemaRefs, options) + } + + const { members, nullable: hasNullMember } = splitNullableUnion( + schemaObj.oneOf || schemaObj.anyOf || [], + ) + const schemas = members.map((s) => schemaToZod(s, document, schemaRefs, options), ) - if (schemas.length === 0) return 'z.unknown()' - if (schemas.length === 1) return wrapNullable(schemas[0] as string) - return wrapNullable(`z.union([${schemas.join(', ')}])`) + if (schemas.length === 0) return hasNullMember ? 'z.null()' : 'z.unknown()' + if (schemas.length === 1) + return wrapNullable(schemas[0] as string, hasNullMember) + return wrapNullable(`z.union([${schemas.join(', ')}])`, hasNullMember) } // Handle enum @@ -263,8 +274,8 @@ function schemaToZodType( const schemaObj = schema as OpenAPIV3_1.SchemaObject - const wrapNullable = (zodType: string): string => { - if (isNullableSchema(schemaObj)) { + const wrapNullable = (zodType: string, forceNullable = false): string => { + if (forceNullable || isNullableSchema(schemaObj)) { return `z.ZodNullable<${zodType}>` } return zodType @@ -289,12 +300,19 @@ function schemaToZodType( // Handle oneOf/anyOf (union) if (schemaObj.oneOf || schemaObj.anyOf) { - const types = (schemaObj.oneOf || schemaObj.anyOf || []).map((s) => - schemaToZodType(s, document, options), + const normalized = normalizeNullableUnion(schemaObj) + if (normalized !== schemaObj) { + return schemaToZodType(normalized, document, options) + } + + const { members, nullable: hasNullMember } = splitNullableUnion( + schemaObj.oneOf || schemaObj.anyOf || [], ) - if (types.length === 0) return 'z.ZodUnknown' - if (types.length === 1) return wrapNullable(types[0] as string) - return wrapNullable(`z.ZodUnion<[${types.join(', ')}]>`) + const types = members.map((s) => schemaToZodType(s, document, options)) + if (types.length === 0) return hasNullMember ? 'z.ZodNull' : 'z.ZodUnknown' + if (types.length === 1) + return wrapNullable(types[0] as string, hasNullMember) + return wrapNullable(`z.ZodUnion<[${types.join(', ')}]>`, hasNullMember) } // Handle enum @@ -442,8 +460,10 @@ function collectSchemaUsage( const content = requestBody.content for (const ct of CONTENT_TYPE_PRIORITY) { const bodyContent = content?.[ct] - if (bodyContent?.schema && '$ref' in bodyContent.schema) { - return extractSchemaNameFromRef(bodyContent.schema.$ref) + if (!bodyContent?.schema) continue + const bodySchema = normalizeNullableUnion(bodyContent.schema) + if ('$ref' in bodySchema) { + return extractSchemaNameFromRef(bodySchema.$ref) } } return null diff --git a/packages/generator/src/openapi-utils.ts b/packages/generator/src/openapi-utils.ts index a41b7e6..9b87fb6 100644 --- a/packages/generator/src/openapi-utils.ts +++ b/packages/generator/src/openapi-utils.ts @@ -108,6 +108,16 @@ export function isErrorStatusCode(statusCode: string): boolean { // Nullable Detection // ============================================================================= +/** + * A schema that may carry the OpenAPI 3.0 `nullable` keyword. OpenAPI 3.1 + * dropped it from the type definitions, but devup-api still accepts it as + * input and uses it as the normalized form of a 3.1 null union. + */ +export type MaybeNullableSchema = ( + | OpenAPIV3_1.SchemaObject + | OpenAPIV3_1.ReferenceObject +) & { nullable?: boolean } + /** * Check if a schema is nullable (OpenAPI 3.0 or 3.1). * OpenAPI 3.0: uses `nullable: true` @@ -123,6 +133,70 @@ export function isNullableSchema(schema: OpenAPIV3_1.SchemaObject): boolean { return false } +/** + * Check if a schema is the JSON Schema `null` type. + * OpenAPI 3.1 expresses nullability of a `$ref` (or of any schema that cannot + * carry a `type` array) as a union member: `anyOf: [..., { type: "null" }]`. + */ +export function isNullTypeSchema( + schema: OpenAPIV3_1.SchemaObject | OpenAPIV3_1.ReferenceObject, +): boolean { + if ('$ref' in schema) { + return false + } + const { type } = schema + if (Array.isArray(type)) { + return type.length > 0 && type.every((t) => t === 'null') + } + return type === 'null' +} + +/** + * Split the members of a union (`anyOf` / `oneOf`) into the members that carry + * an actual type and a nullability flag contributed by `{ type: "null" }`. + */ +export function splitNullableUnion( + union: (OpenAPIV3_1.SchemaObject | OpenAPIV3_1.ReferenceObject)[], +): { + members: (OpenAPIV3_1.SchemaObject | OpenAPIV3_1.ReferenceObject)[] + nullable: boolean +} { + const members = union.filter((member) => !isNullTypeSchema(member)) + return { members, nullable: members.length !== union.length } +} + +/** + * Normalize the OpenAPI 3.1 nullable notation `anyOf: [S, { type: "null" }]` + * into its OpenAPI 3.0 equivalent `{ ...S, nullable: true }`. + * + * Only unions that reduce to a single typed member are collapsed, so genuine + * unions keep generating a union type. Sibling keywords of the union (such as + * `description` or `default`) are preserved. + * + * Returns the given schema unchanged when there is nothing to collapse, so + * callers can detect a collapse by reference identity. + */ +export function normalizeNullableUnion( + schema: MaybeNullableSchema, +): MaybeNullableSchema { + if ('$ref' in schema) { + return schema + } + + const union = schema.anyOf ?? schema.oneOf + if (!union) { + return schema + } + + const { members, nullable } = splitNullableUnion(union) + if (!nullable || members.length !== 1) { + return schema + } + + const { anyOf, oneOf, ...siblings } = schema + return { ...siblings, ...members[0], nullable: true } +} + // ============================================================================= // Type Extraction // =============================================================================