diff --git a/.changeset/exact-optional-property-types.md b/.changeset/exact-optional-property-types.md new file mode 100644 index 000000000..8308d0ea6 --- /dev/null +++ b/.changeset/exact-optional-property-types.md @@ -0,0 +1,5 @@ +--- +'fets': patch +--- + +Support TypeScript `exactOptionalPropertyTypes` for route schemas, OpenAPI option bags, and inferred optional object fields diff --git a/packages/fets/src/plugins/openapi.ts b/packages/fets/src/plugins/openapi.ts index f3f618061..dde725eab 100644 --- a/packages/fets/src/plugins/openapi.ts +++ b/packages/fets/src/plugins/openapi.ts @@ -16,37 +16,37 @@ function isOptionalSchema(schema: any): boolean { } export interface SwaggerUIOpts { - spec?: OpenAPIDocument; - dom_id?: string; - displayOperationId?: boolean; - tryItOutEnabled?: boolean; - requestSnippetsEnabled?: boolean; - displayRequestDuration?: boolean; - defaultModelRendering?: 'model' | 'example' | 'schema'; - defaultModelExpandDepth?: number; - defaultModelsExpandDepth?: number; - docExpansion?: 'none' | 'list' | 'full'; - filter?: boolean; - maxDisplayedTags?: number; - showExtensions?: boolean; - showCommonExtensions?: boolean; - tagsSorter?: 'alpha'; - operationsSorter?: 'alpha'; - showTags?: boolean; - showMutatedRequest?: boolean; - oauth2RedirectUrl?: string; - validatorUrl?: string; - deepLinking?: boolean; - presets?: any[]; - plugins?: any[]; - layout?: string; + spec?: OpenAPIDocument | undefined; + dom_id?: string | undefined; + displayOperationId?: boolean | undefined; + tryItOutEnabled?: boolean | undefined; + requestSnippetsEnabled?: boolean | undefined; + displayRequestDuration?: boolean | undefined; + defaultModelRendering?: 'model' | 'example' | 'schema' | undefined; + defaultModelExpandDepth?: number | undefined; + defaultModelsExpandDepth?: number | undefined; + docExpansion?: 'none' | 'list' | 'full' | undefined; + filter?: boolean | undefined; + maxDisplayedTags?: number | undefined; + showExtensions?: boolean | undefined; + showCommonExtensions?: boolean | undefined; + tagsSorter?: 'alpha' | undefined; + operationsSorter?: 'alpha' | undefined; + showTags?: boolean | undefined; + showMutatedRequest?: boolean | undefined; + oauth2RedirectUrl?: string | undefined; + validatorUrl?: string | undefined; + deepLinking?: boolean | undefined; + presets?: any[] | undefined; + plugins?: any[] | undefined; + layout?: string | undefined; } export type OpenAPIPluginOptions = { oasEndpoint: string | false; swaggerUIEndpoint: string | false; swaggerUIOpts: SwaggerUIOpts; - includeValidationErrors?: boolean; + includeValidationErrors?: boolean | undefined; }; const requestValidationErrorSchema = { diff --git a/packages/fets/src/typed-fetch.ts b/packages/fets/src/typed-fetch.ts index 0d2c807e5..281fc9673 100644 --- a/packages/fets/src/typed-fetch.ts +++ b/packages/fets/src/typed-fetch.ts @@ -42,11 +42,10 @@ export type StatusCode = export type NotOkStatusCode = Exclude; -export type TypedBody< - TJSON, - TFormData extends Record, - THeaders extends Record, -> = Omit & { +export type TypedBody = Omit< + Body, + 'json' | 'formData' | 'headers' +> & { /** * The `json()` method takes the stream and reads it to completion. * It returns a promise which resolves with the result of parsing the body text as JSON. @@ -77,7 +76,7 @@ type Maybe = undefined | null; type UndefinedToNull = T extends undefined ? Exclude | null : T; -export interface TypedHeaders> { +export interface TypedHeaders> { append( name: TName, value: TName extends keyof TMap ? TMap[TName] : string, @@ -306,9 +305,9 @@ export type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS' | 'CONNECT' | 'TRACE'; export type TypedRequestInit< - THeaders extends Record, + THeaders extends object, TMethod extends HTTPMethod, - TFormData extends Record, + TFormData extends object, > = Omit & { method: TMethod; headers: TypedHeaders; @@ -317,11 +316,10 @@ export type TypedRequestInit< export type TypedRequest< TJSON = any, - TFormData extends Record = Record< - string, - FormDataEntryValue | undefined - >, - THeaders extends Record = Record, + // `object` (not `Record<…| undefined>`) so EOPT optional fields like + // `{ description?: string; file: File }` remain valid form/header maps. + TFormData extends object = Record, + THeaders extends object = Record, TMethod extends HTTPMethod = HTTPMethod, TQueryParams = any, TPathParams extends Record = Record, @@ -334,19 +332,16 @@ export type TypedRequest< }; export type TypedRequestCtor = new < - THeaders extends Record, + THeaders extends object, TMethod extends HTTPMethod, - TFormData extends Record, + TFormData extends object, >( input: string | URL, init?: TypedRequestInit, ) => TypedRequest; export interface TypedFormData< - TMap extends Record = Record< - string, - FormDataEntryValue | undefined - >, + TMap extends object = Record, > { append( name: TName, diff --git a/packages/fets/src/types.ts b/packages/fets/src/types.ts index fc9712bd0..4ae8d5546 100644 --- a/packages/fets/src/types.ts +++ b/packages/fets/src/types.ts @@ -30,50 +30,66 @@ export type JSONSerializer = (obj: any) => string; export type JSONSchema = Exclude; +/** + * Schema accepted by route definitions. + * Plain JSON Schema objects and TypeBox schemas are both allowed. + * Neither is reliably assignable to `json-schema-to-ts`'s `JSONSchema` under + * `exactOptionalPropertyTypes` (optional props often include `| undefined`), so routes + * use this structural type while `FromSchema` still resolves the concrete schema. + */ +export type RouteSchema = { + [key: string]: unknown; + static?: unknown; +}; + export interface OpenAPIInfo { - title?: string; - description?: string; - version?: string; - license?: { - name?: string; - url?: string; - }; - termsOfService?: string; - contact?: { - name?: string; - url?: string; - email?: string; - }; + title?: string | undefined; + description?: string | undefined; + version?: string | undefined; + license?: + | { + name?: string | undefined; + url?: string | undefined; + } + | undefined; + termsOfService?: string | undefined; + contact?: + | { + name?: string | undefined; + url?: string | undefined; + email?: string | undefined; + } + | undefined; } export type OpenAPIPathObject = Record & { - parameters?: OpenAPIParameterObject[]; + parameters?: OpenAPIParameterObject[] | undefined; }; export interface OpenAPIParameterObject { name: string; in: 'path' | 'query' | 'header' | 'cookie'; - required?: boolean; + required?: boolean | undefined; schema?: any; } export interface OpenAPIRequestBodyObject { - content?: Record; + content?: Record | undefined; } export interface OpenAPIOperationObject { - operationId?: string; - description?: string; - tags?: string[]; - parameters?: OpenAPIParameterObject[]; - requestBody?: OpenAPIRequestBodyObject; - responses?: Record; - security?: any[]; + operationId?: string | undefined; + description?: string | undefined; + tags?: string[] | undefined; + parameters?: OpenAPIParameterObject[] | undefined; + requestBody?: OpenAPIRequestBodyObject | undefined; + responses?: Record | undefined; + security?: any[] | undefined; } export interface OpenAPIResponseObject { - description?: string; - content?: Record; + description?: string | undefined; + content?: Record | undefined; } export interface OpenAPIMediaTypeObject { @@ -81,46 +97,48 @@ export interface OpenAPIMediaTypeObject { } export type OpenAPIDocument = { - openapi?: string; - info?: OpenAPIInfo; + openapi?: string | undefined; + info?: OpenAPIInfo | undefined; servers?: | { url: string; }[] - | string[]; - paths?: Record; + | string[] + | undefined; + paths?: Record | undefined; components?: unknown; }; export interface RouterOpenAPIOptions< TComponents extends RouterComponentsBase, > extends OpenAPIDocument { - endpoint?: string | false; - components?: TComponents; - includeValidationErrors?: boolean; + endpoint?: string | false | undefined; + components?: TComponents | undefined; + includeValidationErrors?: boolean | undefined; } export interface RouterSwaggerUIOptions extends SwaggerUIOpts { - endpoint?: string | false; + endpoint?: string | false | undefined; } export interface RouterOptions< TServerContext, TComponents extends RouterComponentsBase, > extends ServerAdapterOptions { - base?: string; + base?: string | undefined; + /** Must stay optional-without-`undefined` to match `ServerAdapterOptions` under EOPT. */ plugins?: RouterPlugin[]; - openAPI?: RouterOpenAPIOptions; - swaggerUI?: RouterSwaggerUIOptions; - landingPage?: boolean; + openAPI?: RouterOpenAPIOptions | undefined; + swaggerUI?: RouterSwaggerUIOptions | undefined; + landingPage?: boolean | undefined; - onError?: ErrorHandler; + onError?: ErrorHandler | undefined; } export type RouterComponentsBase = { - schemas?: Record; - securitySchemes?: Record; + schemas?: Record | undefined; + securitySchemes?: Record | undefined; }; export type BasicAuthSecurityScheme = { @@ -280,13 +298,18 @@ export type DirectType = T extends { static: infer U } ? { [K in Extract]: DirectType; } & { - [K in Exclude]?: DirectType; + // `| undefined` keeps optionals usable under exactOptionalPropertyTypes + [K in Exclude]?: + | DirectType + | undefined; } : T extends { type: 'object'; properties: infer Props extends Record; } - ? { [K in keyof Props]?: DirectType } + ? { + [K in keyof Props]?: DirectType | undefined; + } : T extends { type: 'object' } ? Record : unknown; @@ -310,7 +333,23 @@ type DirectIntersectionType = Items extends type UseDirectType = HasCircularAnyOfRef extends true ? true : Circular extends true ? true : false; -export type FromSchema = +/** + * Under `exactOptionalPropertyTypes`, optional props (`prop?: T`) do not accept + * explicit `undefined`. Schema-inferred objects often produce values like + * `string | undefined` from `.get()` / missing JSON fields — widen optionals so + * those remain assignable. + */ +type AddUndefToOptionals = T extends any + ? T extends object + ? T extends readonly any[] + ? T + : { + [K in keyof T]: {} extends Pick ? T[K] | undefined : T[K]; + } + : T + : never; + +type FromSchemaResult = /* T extends { type: 'integer'; minimum: number; maximum: number } ? RangedJSONSchema : */ T extends { static: infer U; } @@ -348,9 +387,11 @@ export type FromSchema = > : never; +export type FromSchema = AddUndefToOptionals>; + export type FromRouterComponentSchema, TName extends string> = TRouter extends Router - ? TComponents extends Required + ? TComponents extends { schemas: Record } ? FromSchema : never : never; @@ -394,8 +435,8 @@ export interface RouterBaseObject< TPath extends string, TTypedRequest extends TypedRequest< any, - Record, - Record, + any, + any, TMethod, any, Record, string> @@ -544,18 +585,29 @@ export type RouterPlugin< onRouteHandle?: OnRouteHandleHook; }; -type ObjectSchemaWithPrimitiveProperties = JSONSchema & { +/** + * Structural object schema accepted by routes (plain JSON Schema or TypeBox). + * Intentionally not tied to `json-schema-to-ts`'s `JSONSchema`, which TypeBox + * cannot satisfy under `exactOptionalPropertyTypes`. + */ +type ObjectSchema = { + type: 'object'; + properties?: Record; + required?: readonly string[]; + [key: string]: unknown; +}; + +type ObjectSchemaWithPrimitiveProperties = { type: 'object'; properties: Record< string, { type: 'string' | 'number' | 'integer' | 'boolean' | 'null'; + [key: string]: unknown; } >; -}; - -type ObjectSchema = JSONSchema & { - type: 'object'; + required?: readonly string[]; + [key: string]: unknown; }; export type RouteSchemas = { @@ -563,10 +615,10 @@ export type RouteSchemas = { headers?: ObjectSchemaWithPrimitiveProperties; params?: ObjectSchemaWithPrimitiveProperties; query?: ObjectSchema; - json?: JSONSchema; + json?: RouteSchema; formData?: ObjectSchema; }; - responses?: StatusCodeMap; + responses?: StatusCodeMap; }; export type RouterSDKOpts< @@ -614,7 +666,7 @@ export type RouterSDK< export type FromSchemaWithComponents< TComponents, - TSchema extends JSONSchema, + TSchema, > = TComponents extends { schemas: Record; } @@ -625,6 +677,8 @@ export type FromSchemaWithComponents< > : FromSchema; +type EnsureObject = T extends object ? T : {}; + export type TypedRequestFromRouteSchemas< TComponents extends RouterComponentsBase, TRouteSchemas extends RouteSchemas, @@ -632,36 +686,31 @@ export type TypedRequestFromRouteSchemas< TPath extends string, > = TRouteSchemas extends { request: Required['request'] } ? TypedRequest< - TRouteSchemas['request'] extends { json: JSONSchema } + TRouteSchemas['request'] extends { json: RouteSchema } ? FromSchemaWithComponents : {}, - TRouteSchemas['request'] extends { formData: JSONSchema } - ? FromSchemaWithComponents< - TComponents, - TRouteSchemas['request']['formData'] - > extends Record - ? FromSchemaWithComponents - : never + TRouteSchemas['request'] extends { formData: ObjectSchema } + ? EnsureObject< + FromSchemaWithComponents + > : {}, - TRouteSchemas['request'] extends { headers: JSONSchema } - ? FromSchemaWithComponents extends Record< - string, - string + TRouteSchemas['request'] extends { headers: ObjectSchemaWithPrimitiveProperties } + ? EnsureObject< + FromSchemaWithComponents > - ? FromSchemaWithComponents - : never : {}, TMethod, - TRouteSchemas['request'] extends { query: JSONSchema } + TRouteSchemas['request'] extends { query: ObjectSchema } ? FromSchemaWithComponents : {}, - TRouteSchemas['request'] extends { params: JSONSchema } - ? FromSchemaWithComponents extends Record< - string, - any - > - ? FromSchemaWithComponents - : never + TRouteSchemas['request'] extends { params: ObjectSchemaWithPrimitiveProperties } + ? EnsureObject< + FromSchemaWithComponents + > extends Record + ? EnsureObject< + FromSchemaWithComponents + > + : Record, string> : Record, string> > : TypedRequest< @@ -676,11 +725,11 @@ export type TypedRequestFromRouteSchemas< export type TypedResponseFromRouteSchemas< TComponents extends RouterComponentsBase, TRouteSchemas extends RouteSchemas, -> = TRouteSchemas extends { responses: StatusCodeMap } +> = TRouteSchemas extends { responses: StatusCodeMap } ? TypedResponseWithJSONStatusMap<{ [ TStatusCode in keyof TRouteSchemas['responses'] - ]: TRouteSchemas['responses'][TStatusCode] extends JSONSchema + ]: TRouteSchemas['responses'][TStatusCode] extends RouteSchema ? FromSchemaWithComponents : never; }> @@ -696,7 +745,7 @@ export type RouteWithSchemasOpts< TTypedResponse extends TypedResponseFromRouteSchemas, > = { schemas: TRouteSchemas; - security?: SecuritySchemeRefsFromComponents[]; + security?: SecuritySchemeRefsFromComponents[] | undefined; } & RouteWithTypesOpts; export type SecuritySchemeRefsFromComponents = @@ -712,19 +761,19 @@ export type RouteWithTypesOpts< TPath extends string, TTypedRequest extends TypedRequest< any, - Record, - Record, + any, + any, TMethod, any, Record, string> >, TTypedResponse extends TypedResponse, > = { - operationId?: string; - description?: string; - method?: TMethod; - tags?: string[]; - internal?: boolean; + operationId?: string | undefined; + description?: string | undefined; + method?: TMethod | undefined; + tags?: string[] | undefined; + internal?: boolean | undefined; path: TPath; handler: RouteHandler; }; diff --git a/tsconfig.json b/tsconfig.json index 0a150191c..f74150fd8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,6 +20,7 @@ "skipLibCheck": true, "strict": true, + "exactOptionalPropertyTypes": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true,