Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/exact-optional-property-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'fets': patch
---

Support TypeScript `exactOptionalPropertyTypes` for route schemas, OpenAPI option bags, and inferred optional object fields
50 changes: 25 additions & 25 deletions packages/fets/src/plugins/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +19 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Confirm that the defaulted options are assigned before swaggerUIOpts is spread.
rg -n -C 12 'spec: router\.openAPIDocument|swaggerUIOpts' packages/fets/src/plugins/openapi.ts

# Locate OpenAPI or Swagger UI tests that should cover explicit undefined values.
fd -t f . packages/fets | rg -i '(openapi|swagger).*(test|spec)|(test|spec).*(openapi|swagger)' || true

Repository: ardatan/feTS

Length of output: 3008


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== SwaggerUIOpts type =="
sed -n '25,44p' packages/fets/src/plugins/openapi.ts | cat -n

echo
echo "== relevant openapi tests =="
sed -n '1,240p' packages/fets/tests/plugins/openapi.spec.ts | cat -n

echo
echo "== tsconfig exactOptionalPropertyTypes =="
fd -t f 'tsconfig.*json' . -x sh -c 'echo "--- {}"; grep -n "exactOptionalPropertyTypes" "{}" || true'

Repository: ardatan/feTS

Length of output: 5343


Keep defaulted Swagger UI options from accepting explicit undefined.

With exactOptionalPropertyTypes: true, { spec: undefined } is permitted. The handler spreads swaggerUIOpts after the defaults in JSON.stringify, so explicit undefined overwrites the plugin default before serialization and leaves the option out of the Swagger UI configuration. Remove | undefined from the defaulted SwaggerUIOpts properties, or strip undefined values before the spread.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/fets/src/plugins/openapi.ts` around lines 19 - 27, Update the
defaulted properties in SwaggerUIOpts, including spec and the related
display/model options, to reject explicit undefined under
exactOptionalPropertyTypes by removing their | undefined unions; preserve
optional omission while keeping the existing plugin defaults and spread behavior
unchanged.

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 = {
Expand Down
33 changes: 14 additions & 19 deletions packages/fets/src/typed-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,10 @@ export type StatusCode =

export type NotOkStatusCode = Exclude<StatusCode, OkStatusCode>;

export type TypedBody<
TJSON,
TFormData extends Record<string, FormDataEntryValue | undefined>,
THeaders extends Record<string, string | undefined>,
> = Omit<Body, 'json' | 'formData' | 'headers'> & {
export type TypedBody<TJSON, TFormData extends object, THeaders extends object> = Omit<
Body,
'json' | 'formData' | 'headers'
> & {
Comment on lines +45 to +48
/**
* 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.
Expand Down Expand Up @@ -77,7 +76,7 @@ type Maybe = undefined | null;

type UndefinedToNull<T> = T extends undefined ? Exclude<T, undefined> | null : T;

export interface TypedHeaders<TMap extends Record<string, string | undefined>> {
export interface TypedHeaders<TMap extends object = Record<string, string | undefined>> {
append<TName extends DefaultHTTPHeaders | keyof TMap>(
name: TName,
value: TName extends keyof TMap ? TMap[TName] : string,
Expand Down Expand Up @@ -306,9 +305,9 @@ export type HTTPMethod =
'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS' | 'CONNECT' | 'TRACE';

export type TypedRequestInit<
THeaders extends Record<string, string | undefined>,
THeaders extends object,
TMethod extends HTTPMethod,
TFormData extends Record<string, FormDataEntryValue | undefined>,
TFormData extends object,
> = Omit<RequestInit, 'method' | 'headers' | 'body'> & {
method: TMethod;
headers: TypedHeaders<THeaders>;
Expand All @@ -317,11 +316,10 @@ export type TypedRequestInit<

export type TypedRequest<
TJSON = any,
TFormData extends Record<string, FormDataEntryValue | undefined> = Record<
string,
FormDataEntryValue | undefined
>,
THeaders extends Record<string, string | undefined> = Record<string, string | undefined>,
// `object` (not `Record<…| undefined>`) so EOPT optional fields like
// `{ description?: string; file: File }` remain valid form/header maps.
TFormData extends object = Record<string, FormDataEntryValue | undefined>,
THeaders extends object = Record<string, string | undefined>,
TMethod extends HTTPMethod = HTTPMethod,
TQueryParams = any,
TPathParams extends Record<string, any> = Record<string, any>,
Expand All @@ -334,19 +332,16 @@ export type TypedRequest<
};

export type TypedRequestCtor = new <
THeaders extends Record<string, string | undefined>,
THeaders extends object,
TMethod extends HTTPMethod,
TFormData extends Record<string, FormDataEntryValue | undefined>,
TFormData extends object,
>(
input: string | URL,
init?: TypedRequestInit<THeaders, TMethod, TFormData>,
) => TypedRequest<any, TFormData, THeaders, TMethod, any, any>;

export interface TypedFormData<
TMap extends Record<string, FormDataEntryValue | undefined> = Record<
string,
FormDataEntryValue | undefined
>,
TMap extends object = Record<string, FormDataEntryValue | undefined>,
> {
append<TName extends keyof TMap>(
name: TName,
Expand Down
Loading
Loading