Skip to content

fix: support exactOptionalPropertyTypes - #4087

Draft
ardatan wants to merge 1 commit into
masterfrom
fix/exact-optional-property-types
Draft

fix: support exactOptionalPropertyTypes#4087
ardatan wants to merge 1 commit into
masterfrom
fix/exact-optional-property-types

Conversation

@ardatan

@ardatan ardatan commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Summary

  • Enable exactOptionalPropertyTypes in the repo tsconfig and close #402
  • Widen route schema input types so TypeBox and plain JSON Schema objects remain assignable under EOPT (they are not assignable to json-schema-to-ts's JSONSchema)
  • Mark OpenAPI / Swagger UI / router option bags with | undefined where values may be omitted or explicitly undefined
  • Make schema-inferred optional object fields accept explicit undefined (including in circular DirectType graphs)

Test plan

  • npm run ts:check passes with exactOptionalPropertyTypes: true
  • CI green on this PR

Enable exactOptionalPropertyTypes in the repo tsconfig and adjust route
schema / OpenAPI option types so TypeBox and plain JSON Schema remain
usable. Optional inferred object fields also accept explicit undefined.

Closes #402
Copilot AI lite review requested due to automatic review settings August 7, 2026 21:33
@ardatan
ardatan marked this pull request as draft August 7, 2026 21:34
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added improved TypeScript support for optional properties and structural route schemas.
    • Expanded typed request and response compatibility for headers, form data, path parameters, and schema-derived objects.
    • OpenAPI and router configuration options now support explicitly undefined optional values.
  • Bug Fixes

    • Improved compatibility when projects enable TypeScript’s exactOptionalPropertyTypes option.

Walkthrough

TypeScript exactOptionalPropertyTypes is now enabled. Route schemas, schema-derived types, OpenAPI options, router options, and typed-fetch generics were updated to support explicit undefined and structural object types.

Changes

Exact optional property type support

Layer / File(s) Summary
Optional-property contracts and conversion
tsconfig.json, packages/fets/src/types.ts
Enables exactOptionalPropertyTypes. Optional OpenAPI and router properties explicitly accept undefined. Schema-derived optional fields are widened recursively.
Structural route schema inference
packages/fets/src/types.ts
Adds structural RouteSchema support. Request and response inference normalizes form-data, headers, and path parameters with EnsureObject.
Typed fetch and OpenAPI option compatibility
packages/fets/src/typed-fetch.ts, packages/fets/src/plugins/openapi.ts, .changeset/exact-optional-property-types.md
Typed-fetch maps use object constraints while preserving existing defaults. OpenAPI plugin options explicitly accept undefined. A patch changeset records the update.

Estimated code review effort: 4 (Complex) | ~45 minutes

Poem

I’m a rabbit with types in my tray,
Optional fields now know what to say.
undefined joins the view,
Schemas grow structures true,
And strict TypeScript hops on its way.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies support for TypeScript exactOptionalPropertyTypes, which is the primary change.
Description check ✅ Passed The description accurately summarizes the compiler setting, type changes, linked issue, and validation performed.
Linked Issues check ✅ Passed The changes enable exactOptionalPropertyTypes and update affected public types as requested by issue #402.
Out of Scope Changes check ✅ Passed The changeset, type updates, compiler configuration, and option updates directly support the linked issue objectives.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/exact-optional-property-types

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed: dependency version conflict. Check your lock file or package.json.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

🚀 Snapshot Release (alpha)

The latest changes of this PR are available as alpha on npm (based on the declared changesets):

Package Version Info
fets 0.8.9-alpha-20260807213444-4f2f3a267f5e94ae40c9954d459c208d360b7942 npm ↗︎ unpkg ↗︎

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

💻 Website Preview

The latest changes are available as preview in: https://pr-4087.fets-3ku.pages.dev

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

✅ Benchmark Results

     ✓ no_errors{server:node-http,mode:no-schema}
     ✓ expected_result{server:node-http,mode:no-schema}
     ✓ no_errors{server:node-http,mode:json-schema}
     ✓ expected_result{server:node-http,mode:json-schema}
     ✓ no_errors{server:uWebSockets,mode:no-schema}
     ✓ expected_result{server:uWebSockets,mode:no-schema}
     ✓ no_errors{server:uWebSockets,mode:json-schema}
     ✓ expected_result{server:uWebSockets,mode:json-schema}

     checks......................................: 100.00% ✓ 1135112     ✗ 0     
     data_received...............................: 103 MB  859 kB/s
     data_sent...................................: 72 MB   601 kB/s
     http_req_blocked............................: avg=1.03µs   min=521ns    med=881ns    max=225.4µs  p(90)=1.38µs   p(95)=1.63µs  
     http_req_connecting.........................: avg=0ns      min=0s       med=0s       max=120.37µs p(90)=0s       p(95)=0s      
     http_req_duration...........................: avg=151.79µs min=94.28µs  med=144.24µs max=12.82ms  p(90)=165.76µs p(95)=172.09µs
       { expected_response:true }................: avg=151.79µs min=94.28µs  med=144.24µs max=12.82ms  p(90)=165.76µs p(95)=172.09µs
     ✓ { server:node-http,mode:json-schema }.....: avg=159.51µs min=110.77µs med=151.61µs max=8.08ms   p(90)=172.3µs  p(95)=177.93µs
     ✓ { server:node-http,mode:no-schema }.......: avg=154.73µs min=100.61µs med=144.48µs max=12.82ms  p(90)=165.92µs p(95)=173.68µs
     ✓ { server:uWebSockets,mode:json-schema }...: avg=149.67µs min=102.28µs med=143.52µs max=8.51ms   p(90)=162.89µs p(95)=168.07µs
     ✓ { server:uWebSockets,mode:no-schema }.....: avg=143.94µs min=94.28µs  med=137.9µs  max=10.38ms  p(90)=157µs    p(95)=162.2µs 
     http_req_failed.............................: 0.00%   ✓ 0           ✗ 567556
     http_req_receiving..........................: avg=19.67µs  min=7.7µs    med=19.3µs   max=5.68ms   p(90)=24.73µs  p(95)=26.04µs 
     http_req_sending............................: avg=5.79µs   min=3.36µs   med=5.03µs   max=1.1ms    p(90)=8.16µs   p(95)=8.96µs  
     http_req_tls_handshaking....................: avg=0s       min=0s       med=0s       max=0s       p(90)=0s       p(95)=0s      
     http_req_waiting............................: avg=126.33µs min=68.11µs  med=118.93µs max=12.77ms  p(90)=137.46µs p(95)=143.08µs
     http_reqs...................................: 567556  4729.555301/s
     iteration_duration..........................: avg=207.82µs min=139.74µs med=200.46µs max=13.04ms  p(90)=225.51µs p(95)=233.46µs
     iterations..................................: 567556  4729.555301/s
     vus.........................................: 1       min=1         max=1   
     vus_max.....................................: 2       min=2         max=2   

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with 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.

Inline comments:
In `@packages/fets/src/plugins/openapi.ts`:
- Around line 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.

In `@packages/fets/src/types.ts`:
- Around line 342-348: Update the mapped type in AddUndefToOptionals so each
property value is recursively transformed with AddUndefToOptionals before
applying the optional-property undefined widening. Preserve the existing object
and readonly-array handling while ensuring nested optional objects and their
descendants accept undefined under exactOptionalPropertyTypes.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bdd2fcca-48d0-4b10-8721-673d0b1204f0

📥 Commits

Reviewing files that changed from the base of the PR and between 317ad01 and 4f2f3a2.

📒 Files selected for processing (5)
  • .changeset/exact-optional-property-types.md
  • packages/fets/src/plugins/openapi.ts
  • packages/fets/src/typed-fetch.ts
  • packages/fets/src/types.ts
  • tsconfig.json

Comment on lines +19 to +27
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;

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.

Comment on lines +342 to +348
type AddUndefToOptionals<T> = T extends any
? T extends object
? T extends readonly any[]
? T
: {
[K in keyof T]: {} extends Pick<T, K> ? T[K] | undefined : T[K];
}

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

printf 'Files matching types.ts:\n'
fd -a 'types\.ts$' . | sed 's#^\./##' | head -50

printf '\nRelevant section in packages/fets/src/types.ts:\n'
sed -n '310,380p' packages/fets/src/types.ts

printf '\nSearch FromSchemaOriginal and AddUndefToOptionals:\n'
rg -n "FromSchemaOriginal|AddUndefToOptionals|exactOptionalPropertyTypes" .

Repository: ardatan/feTS

Length of output: 3555


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Section around FromSchemaResult and FromSchema:\n'
sed -n '350,405p' packages/fets/src/types.ts

printf '\nTypeScript compiler availability:\n'
if command -v tsc >/dev/null 2>&1; then
  tsc --version
else
  printf 'tsc not in PATH\n'
  npm_exec="$(command -v npm-exec >/dev/null 2>&1 && echo npm-exec || true)"
  if [ -n "$npm_exec" ]; then npm-exec tsc --version; else printf 'npm-exec tsc unavailable\n'; fi
fi

printf '\nMinimal TypeScript behavior probe for current and proposed mapping:\n'
python3 - <<'PY'
from pathlib import Path
p = Path('packages/fets/src/types.ts')
print('packages/fets/src/types.ts exists:', p.exists())
text = p.read_text()
needle = 'type AddUndefToOptionals<T> = T extends any'
idx = text.find(needle)
if idx == -1:
    raise SystemExit(1)
end = text.find('type FromSchemaResult<T>', idx)
print(text[idx:end])
print('add_undef_to_optionals_defined', idx != -1)
print('FromSchemaResult_defined', text.find('type FromSchemaResult<T>', idx) != -1)
print('FromSchema_applies_current_type', 'export type FromSchema<T> = AddUndefToOptionals<FromSchemaResult<T>>;' in text)
PY

Repository: ardatan/feTS

Length of output: 2216


🌐 Web query:

TypeScript exactOptionalPropertyTypes optional property undefined explicit assignability documented

💡 Result:

The exactOptionalPropertyTypes compiler option in TypeScript provides stricter, more predictable behavior for optional object properties [1][2]. When exactOptionalPropertyTypes is enabled (set to true), TypeScript enforces a clear distinction between a property that is absent from an object and a property that is present but set to undefined [1][3]. Key implications include: 1. Explicit Assignability: Under this setting, an optional property defined as prop?: T will not accept undefined as a value [1][4]. Attempting to explicitly assign undefined to such a property will result in a compiler error (e.g., "Type 'undefined' is not assignable to type 'T'") [1][5]. 2. Distinguishing Absence from Undefined: The flag enforces that "optional" means the property can be omitted entirely, but if the property is present, it must conform to the declared type [3][4]. This is distinct from standard behavior where optional properties are treated as T | undefined, implicitly allowing undefined even if it was not explicitly included in the type definition [3][6]. 3. Requirement for Explicit Union: If your code requires the ability to explicitly assign undefined to an optional property, you must update the type definition to include it, such as prop?: T | undefined [1][3][4]. This feature is not part of the standard strict mode suite because it is considered a significant breaking change for existing codebases that relied on the traditional behavior of allowing undefined in optional properties [6]. Using this flag helps catch bugs related to object shape mismatches and improves the accuracy of type contracts [3][4]. Top results: [1][3][6][4]

Citations:


🌐 Web query:

TypeScript exactOptionalPropertyTypes explicit undefined property optional value type optional prop type assignment

💡 Result:

The exactOptionalPropertyTypes compiler option in TypeScript provides stricter handling for optional properties by distinguishing between a property being entirely absent from an object and a property being explicitly set to undefined [1][2]. Standard Behavior (Default) When exactOptionalPropertyTypes is disabled (the default), TypeScript treats an optional property declared as prop?: T as having the type T | undefined [1][3]. This means you can assign undefined to the property even if it is not explicitly included in the type definition [2][4]. In this state, the compiler considers the following three scenarios valid for prop?: T: 1. The property is absent from the object [1][2]. 2. The property is present with a value of type T [1][2]. 3. The property is present with the value undefined [1][2]. Behavior with exactOptionalPropertyTypes: true When this flag is enabled, TypeScript enforces that optional properties only accept the declared type T or complete absence [1][2]. You cannot explicitly assign undefined to the property unless undefined is part of the explicit type union [1][2][4]. For example: interface User { name?: string; } // Allowed (property is absent) const u1: User = {}; // Allowed (property has value of type string) const u2: User = { name: "Alice" }; // Error when exactOptionalPropertyTypes: true // Type 'undefined' is not assignable to type 'string' const u3: User = { name: undefined }; To allow undefined as a valid value while still maintaining an optional property, you must explicitly include it in the type definition: interface User { name?: string | undefined; } // Now allowed with exactOptionalPropertyTypes: true const u4: User = { name: undefined }; Why it is not in 'strict' This option is not included in the standard strict flag suite because it is a significant breaking change for existing codebases [5][6]. Many existing type definitions and utility functions historically relied on the assumption that optional properties could accept undefined [5][7]. Enabling this flag requires a developer to audit and potentially update type definitions where explicit undefined assignments were previously allowed [5]. Summary of Key Differences - Read type: Even with exactOptionalPropertyTypes enabled, reading from an optional property will still result in a type of T | undefined, because the property might be missing at runtime [5]. - Write/Assignment type: The restriction primarily affects assignment. It prevents developers from explicitly setting a property to undefined when the type definition does not explicitly allow it [1][5]. This helps catch bugs where the distinction between "missing" and "undefined" is important for downstream logic (e.g., when using Object.keys or the 'in' operator) [1][6].

Citations:


🌐 Web query:

TypeScript mapped type recursive conditional does not apply to value type exactOptionalPropertyTypes optional undefined test

💡 Result:

The behavior you are encountering is rooted in how TypeScript handles optional properties when exactOptionalPropertyTypes is enabled, combined with the way mapped types and recursive conditional types process property types [1][2]. When exactOptionalPropertyTypes is active, an optional property p?: T is strictly interpreted as T (the property may be missing) and not T | undefined [3]. However, the compiler's internal handling of these properties within mapped types can lead to counterintuitive results because the "missing" state of a property is distinct from the type undefined [1]. Key technical reasons for this behavior include: 1. Distinct Types of 'Undefined': When exactOptionalPropertyTypes is enabled, there are effectively two "flavors" of undefined: one representing the explicit value undefined and another representing a "missing" property [1]. Mapped types may struggle to preserve the "missing" state correctly during recursive operations because the index access T[P] can "decay" the missing property into an explicit undefined or force an unexpected union [1]. 2. Homomorphic Mappings: Mapped types that are homomorphic (using [P in keyof T]) are designed to preserve optionality modifiers (?) [4]. When you introduce a recursive conditional type (e.g., T[P] extends object ? ... : ...), you often break this homomorphic property [4]. If the mapping is no longer homomorphic, TypeScript can no longer reliably copy the optionality modifier or respect the exact optionality constraints of the original type [4][5]. 3. Relationship Checks and Assignability: Recent updates (e.g., PR #60890) have addressed issues where homomorphic mappings broke assignability under exactOptionalPropertyTypes [6]. However, complex recursive utilities still frequently face issues where the "missingness" of a property is lost or transformed because the conditional logic inside the mapped type processes the type as T | undefined (or just T) instead of maintaining the original optional property descriptor [1][7]. To work around this, ensure your mapped type remains homomorphic whenever possible [4][5]. If you must use conditional logic, try to perform key filtering in the as clause (key remapping) rather than the in clause to maintain the homomorphic structure, which helps the compiler track the original property modifiers more accurately [8][4]. If you are experiencing unexpected | undefined behavior, note that this is a known area of complexity where the compiler's display of the type and its underlying behavior can diverge [1][7].

Citations:


🌐 Web query:

TypeScript exactOptionalPropertyTypes mapped type conditional T[K] property value undefined explicit assignment union

💡 Result:

When exactOptionalPropertyTypes is enabled, TypeScript distinguishes between a property being missing and a property being present with an undefined value [1]. This significantly affects how mapped types interact with optional properties and how index access types (like T[K]) are evaluated [2][3]. Under exactOptionalPropertyTypes: true: 1. Explicit Assignment: You cannot explicitly assign undefined to an optional property (e.g., obj.prop = undefined) unless undefined is explicitly included in the property's type definition (e.g., prop?: string | undefined) [1]. 2. Index Access Types (T[K]): When you read an optional property via T[K], the resulting type includes undefined [2][3]. This is because the property can be missing in the object, and reading a missing property yields undefined [2]. This behavior persists even though you cannot assign undefined back to that property [2]. 3. Mapped Types: Homomorphic mapped types (of the form { [K in keyof T]:... }) generally preserve optionality, but they can be sensitive to how they handle the T[K] values [3][4][5]. If you manipulate T[K] using conditional types—for example, to strip undefined from a union—the resulting mapped type may lose its homomorphic nature, causing it to no longer preserve property modifiers (like optionality) [4][5]. 4. Known Issues and Complexity: There are ongoing challenges in TypeScript regarding the consistency of type display and assignability when using mapped types with this flag [3][4][6]. For instance, mapped types that do not exactly mirror the source shape can sometimes create "decay" where the "missing" state of an optional property is conflated with an explicit undefined [3]. When building utilities to manipulate these properties, developers often have to use specific techniques like key remapping (using the as clause) to maintain homomorphic behavior and prevent accidental stripping of modifiers [4][5]. In summary, exactOptionalPropertyTypes restricts writing undefined to optional properties, but it does not remove undefined from the read-only type T[K], as undefined remains the runtime result of accessing a missing property [1][2].

Citations:


🌐 Web query:

TypeScript exactOptionalPropertyTypes {} extends Pick<T K> optional property modifier T[K] explicit undefined

💡 Result:

When the TypeScript compiler option exactOptionalPropertyTypes is enabled, it enforces a stricter rule for optional properties: they cannot be explicitly assigned the value undefined unless undefined is explicitly included in the property's type definition (e.g., prop?: string | undefined) [1][2]. The behavior of Pick<T, K> and indexed access types (T[K]) under this setting is nuanced: 1. Indexing (T[K]): Even with exactOptionalPropertyTypes enabled, the type resulting from T[K] for an optional property K will typically include undefined [3]. This is because T[K] represents the type you get when reading from an object; if the property is missing, the value is undefined [3]. Consequently, even though the compiler prevents you from writing undefined to an optional property, it allows you to read undefined from it [3]. 2. Utility Types like Pick<T, K>: Pick<T, K> preserves the optionality of properties as defined in the source type T [4]. If T has an optional property prop?: string, then Pick<T, 'prop'> will also have prop?: string. Because the optional property modifier ? remains, the constraint imposed by exactOptionalPropertyTypes continues to apply to the resulting type [1][2]. 3. Interaction: There is a known distinction in TypeScript between the "write" type and the "read" type of optional properties when exactOptionalPropertyTypes is active [3][5]. The compiler effectively tracks whether a property is allowed to hold the value undefined [6]. While Pick creates a type that mirrors the original structure, the compiler enforces the restriction against assigning undefined to optional properties regardless of how the type was constructed, provided the property was marked optional in the resulting type [1][4]. In summary, exactOptionalPropertyTypes restricts the assignment of undefined to optional properties [1][3]. While indexed access types (T[K]) retain undefined in their type to account for the possibility of a property being missing at runtime, the compiler uses the optional modifier to prevent the explicit assignment of undefined at compile time [3][6].

Citations:


🌐 Web query:

TypeScript {} extends Pick<T K> exactOptionalPropertyTypes optional property

💡 Result:

The conditional type {} extends Pick<T, K> is a common TypeScript technique used to detect whether a property K in type T is optional [1][2]. This pattern relies on the fact that an empty object {} is assignable to an object type if all of its properties are optional [3][2]. When exactOptionalPropertyTypes is enabled, the behavior of optional properties becomes stricter: they no longer implicitly accept undefined as a valid value unless explicitly included in the type (e.g., prop?: string | undefined) [4][5]. ### How the Pattern Works - Pick<T, K> creates an object type containing only the property K from T [2]. - If property K is optional (i.e., K?: V), then Pick<T, K> effectively becomes { K?: V }. - Because {} is assignable to any object type where all properties are optional, {} extends Pick<T, K> evaluates to true when K is optional [3][2]. - If property K is required (i.e., K: V), Pick<T, K> becomes { K: V }. The empty object {} cannot satisfy this, so the expression evaluates to false [2]. ### Interaction with exactOptionalPropertyTypes While this pattern is primarily designed to identify the optionality modifier (?), its reliability can be affected by the exactOptionalPropertyTypes flag [6][7]. 1. Distinguishing Optionality: The pattern successfully distinguishes between required properties and those marked with the ? modifier, regardless of whether exactOptionalPropertyTypes is enabled [6]. 2. undefined Handling: The flag changes whether undefined is a valid assignment for an optional property, but it does not change the fact that an optional property is structurally "missing" by default [4][5]. The check {} extends Pick<T, K> remains a structural test for whether an object can be constructed without that key [3][8]. 3. Edge Cases: If you rely on this pattern to infer types, be aware that properties explicitly typed as prop: V | undefined (without the ? modifier) are required, and the check will correctly identify them as such (evaluating to false) [2]. Conversely, if a property is prop?: V | undefined, the check evaluates to true because of the ? modifier [9]. Developers often use this to create utility types, such as extracting optional or required keys from an interface [1][3][10]: typescript // Extracts keys that are optional type OptionalKeys<T> = { [K in keyof T]-?: {} extends Pick<T, K> ? K : never }[keyof T]; In summary, the pattern works as a structural check for the ? modifier, which persists even when exactOptionalPropertyTypes is enabled to enforce stricter assignment rules [6][4].

Citations:


Make AddUndefToOptionals recursive.

AddUndefToOptionals currently widens only the optional modifier on the direct object; nested optional object properties keep their original value type. Under exactOptionalPropertyTypes, passing undefined for an inner optional field then rejects unless each property value is transformed recursively.

🤖 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/types.ts` around lines 342 - 348, Update the mapped type in
AddUndefToOptionals so each property value is recursively transformed with
AddUndefToOptionals before applying the optional-property undefined widening.
Preserve the existing object and readonly-array handling while ensuring nested
optional objects and their descendants accept undefined under
exactOptionalPropertyTypes.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Enables TypeScript’s exactOptionalPropertyTypes across the repo and updates the fets public type surface (route schemas, OpenAPI/Swagger options, and schema-derived request/response types) to remain usable under EOPT semantics.

Changes:

  • Turn on exactOptionalPropertyTypes in tsconfig.json.
  • Broaden route-schema input typing (supporting TypeBox + plain JSON Schema) and widen optional fields to accept explicit undefined where needed.
  • Update OpenAPI/Swagger/router option bag types to tolerate omitted vs explicitly-undefined values under EOPT; add a changeset entry.

Reviewed changes

Copilot reviewed 4 out of 5 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tsconfig.json Enables exactOptionalPropertyTypes.
packages/fets/src/types.ts Introduces RouteSchema, widens option bags with `
packages/fets/src/typed-fetch.ts Loosens typed request/body/header generic constraints to work with EOPT-friendly object maps.
packages/fets/src/plugins/openapi.ts Updates Swagger UI options typing for EOPT (`
.changeset/exact-optional-property-types.md Adds a patch changeset describing EOPT support changes.
Suppressed comments (3)

packages/fets/src/typed-fetch.ts:83

  • TypedHeaders is meant to reflect the Fetch Headers API, which always stores/returns string values. With TMap extends object, callers can supply non-string value types and TypeScript will incorrectly type .get()/.entries() as returning those non-string values.

Consider constraining TMap values back to string | undefined (this still works with exact-optional property types).

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,
  ): void;

packages/fets/src/typed-fetch.ts:312

  • TypedRequestInit currently allows THeaders to be any object, which permits non-string header value types while still flowing into TypedHeaders<THeaders>.

Tighten the constraint to Record<string, string | undefined> so header value types stay aligned with the actual Fetch API.

export type TypedRequestInit<
  THeaders extends object,
  TMethod extends HTTPMethod,
  TFormData extends object,
> = Omit<RequestInit, 'method' | 'headers' | 'body'> & {
  method: TMethod;

packages/fets/src/typed-fetch.ts:337

  • TypedRequest / TypedRequestCtor use THeaders extends object, which allows non-string header value types while still flowing into TypedHeaders<THeaders>. Since Fetch Headers always stores/returns strings, this makes request header typings unsound.

Constrain THeaders to Record<string, string | undefined> here as well so type parameters remain consistent and aligned with runtime behavior.

export type TypedRequest<
  TJSON = any,
  // `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>,
> = Omit<Request, 'json' | 'method' | 'headers' | 'formData'> &
  TypedBody<TJSON, TFormData, THeaders> & {
    parsedUrl: URL;
    method: TMethod;
    params: TPathParams;
    query: TQueryParams;
  };

export type TypedRequestCtor = new <
  THeaders extends object,
  TMethod extends HTTPMethod,
  TFormData extends object,

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +45 to +48
export type TypedBody<TJSON, TFormData extends object, THeaders extends object> = Omit<
Body,
'json' | 'formData' | 'headers'
> & {
@ardatan
ardatan force-pushed the master branch 2 times, most recently from 48acead to 94e7115 Compare August 18, 2026 15:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Enable "exactOptionalPropertyTypes": true, in tsconfig.json

2 participants