Skip to content

feat(type-plus)!: reject unknown option keys - #694

Merged
unional merged 5 commits into
mainfrom
feat-strict-options
Sep 14, 2026
Merged

unional merged 5 commits into
mainfrom
feat-strict-options

Conversation

@unional

@unional unional commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

Closes #685. Part of #689.

What changed

  • Every type with an $O options parameter (66 types: predicates, testType, And/Or/Not/Xor, Has*, StringPlus.Includes, ArrayPlus.IsIndexOutOfBound) now constrains it with $StrictOptions<$O, X.$Options>. A misspelled key next to a valid one is an error:
    Type 'true' is not assignable to type '"'exactt' is not a valid option. Did you mean 'exact'?"'.
    
    The error text is byte-identical on TS 5.4 and 7. The message does not name the type, so wrappers that repeat the same constraint still compose.
  • New exports in $type/utils/$strict_options.ts: $StrictOptions, $ErrorMessage (a string with a zero-width space at the end, the ArkType technique), and $ForwardOptions<$O, A, K>.
  • $ForwardOptions is Pick<$O, Exclude<Extract<keyof $O, keyof A>, K>>. It drops the wrapper's own keys and narrows the rest in one type. TypeScript accepts the result against the strict constraint even while $O is generic, on all five compilers. There is no conditional, so the no-strip narrowing trap (a silent never when an own option is passed) cannot happen. The spec covers the wrapper's own option, forwarded options, typos in either set, and the trap itself.
  • Library generic call sites: 42 in testing/test_type.ts and 1 in IsAnyOrNever. They are now strict on their own options and forward through $ForwardOptions, so testType rejects unknown keys too.
  • Docs: a new guide, guides/strict-options.mdx (added to the sidebar), covers both wrapper shapes, the helper, and the trap. Also updated: never/readme.md (the custom-type example, which was already wrong: $never is not an IsNever option), type-branching, the options reference, the Then/Else migration guide, API signatures, legacy readmes and TSDoc.
  • The bench script gains IsObject+exact, Assignable+object and Assignable+object+nondistributive.

Breaking

type Mine<T, $O extends IsObject.$Options> = IsObject<T, $O> stops compiling (the decision is already accepted for v8). The changeset is major and names the break.

Instantiations per use (TS 6.0 / TS 7, 300 uses)

Use Before After
IsObject<T> 20.8 / 20.8 23.8 / 23.8
IsObject<T, { exact: true }> 124.1 / 125.0 140.1 / 141.0
IsNever<T> 11.3 / 11.4 14.3 / 14.6
Assignable<T, object> 34.0 / 34.1 37.0 / 37.4
Assignable<T, object, { distributive: false }> 69.1 / 69.6 84.1 / 84.7

The no-options fast paths from #690/#692 still hold: the body is unchanged, and the constraint adds +3 without options and +15/+16 with options. I tried an as-remapped constraint. It cost more (152 with exact) and broke $ForwardOptions, so I didn't use it.

Verification

  • pnpm --filter type-plus test:type, TS 5.4 / 5.5 / 5.6 / 6.0 / 7: 0 errors, run after each family was converted.
  • pnpm verify:pkg green (272 test files), pnpm docs:llms:check up to date, and biome check clean except the untracked local harness directory.
  • I checked that the new @ts-expect-errors depend on the constraint: with $StrictOptions loosened, the mixed-key cases and the lax-wrapper case report "Unused '@ts-expect-error'".

Error snapshots (#693)

Rebased onto #693. I updated error-snapshots/snapshot.txt (identical on all five compilers):

  • misspelled_key_with_valid_key and key_invalid_for_type (distributive on IsNever): no error before. They now report 'exactt' is not a valid option. Did you mean 'exact'? and 'distributive' is not a valid option.
  • void_branch_on_is_never (IsNever<void, { $void: 'V'; $else: 'E' }>): no error, see below.
  • key_only_typo: was TS2559 "has no properties in common". It now gets the same message and suggestion.
  • wrong_value, assignable_wrong_value, typo_and_wrong_value: same value error, with one added head line ($StrictOptions<…, $Options>).
  • generic_wrapper: now errors, as intended.
  • New probes: generic_wrapper_same_constraint and generic_wrapper_forward report no error. generic_wrapper_forward_typo reports the message.

$void was a real option

#685 cited IsNever<1, { $void: 'V'; $else: 'E' }> as a wrong key, but IsNever resolves a $void branch (since f09ef4d) and only its $Options left it out. On main that call answered 'V' for void. The strict keys would have made the branch impossible to override.

I checked every single-parameter predicate, plus Assignable/NotAssignable/Equal/HasKey/IsOptionalKey, with each special key, comparing whether the key changes the result against whether $Options declares it. Resolved but not declared: $void on IsNever, IsNotNever, IsUnknown, IsNotUnknown, IsNotStrictFunction, and on $SelectInvert (behind IsNotFunction and IsNotNumeric). All of them now declare it, with a can override $void branch spec for each.

The reverse gap is fixed here too. IsPositive, IsNegative, IsInteger and their negations declared $any/$unknown/$never/$void but ignored them. They now route through $Special when one of those keys is passed, with the previous body as the default for every branch, so results without those keys are unchanged. There is a spec for each type and a separate patch changeset.

Cost per use (TS 6.0; TS 7 is within 2.5):

Use Before After
IsPositive<T> 174.9 177.9
IsPositive<T, { selection: 'filter' }> 197.5 207.5
IsNotPositive<T> 199.4 202.4
IsNegative<T> 173.2 176.2
IsNotNegative<T> 188.7 191.7
IsInteger<T> 142.2 145.2
IsNotInteger<T> 166.7 169.7
IsNotInteger<T, { selection: 'filter' }> 186.0 196.0

I also tried a union of key objects (+8 without options) and a key intersection (+6 without options, +6 with filter) as the guard. I kept Extract because the no-options path is the common one.

Still open: those six also declare exact and never read it, although api/number.md lists it as supported.

Generated with Claude Code

https://claude.ai/code/session_01S7D6GMwJiCoJGmdnWD9Q3T

@changeset-bot

changeset-bot Bot commented Sep 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d76aa71

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
type-plus Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@codecov

codecov Bot commented Sep 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (51cd86b) to head (d76aa71).

Additional details and impacted files
@@            Coverage Diff            @@
##              main      #694   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           43        43           
  Lines          231       231           
  Branches        51        51           
=========================================
  Hits           231       231           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

unional and others added 3 commits September 13, 2026 16:04
Add `IsObject<T, { exact: true }>`, `Assignable<T, object>` and
`Assignable<T, object, { distributive: false }>` so the option paths
have a measured cost next to the no-options fast paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S7D6GMwJiCoJGmdnWD9Q3T
Constrain every `$O` options parameter with `$StrictOptions<$O, X.$Options>`,
so a misspelled key next to a valid one is an error naming the key and,
for a prefix match, suggesting the valid key.

Export `$StrictOptions`, `$ErrorMessage`, and `$ForwardOptions`, which
narrows a generic wrapper's `$O` to the keys its target accepts.
`testType` and `IsAnyOrNever` forward their options through it.

BREAKING CHANGE: a generic type that passes its own `$O` to a type-plus
type (`type Mine<T, $O extends IsObject.$Options> = IsObject<T, $O>`) no
longer compiles. Repeat the strict constraint, or forward with
`$ForwardOptions`. See the strict-options guide.

Closes #685

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S7D6GMwJiCoJGmdnWD9Q3T
Unknown keys now report "'<key>' is not a valid option", with a
suggestion on a prefix match; the generic wrapper constrained only by the
options type now errors. Add probes for the two migration shapes that
compile and for a typo forwarded through `$ForwardOptions`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S7D6GMwJiCoJGmdnWD9Q3T
@unional
unional force-pushed the feat-strict-options branch from 415398d to 87c5e24 Compare September 13, 2026 23:05
…olve

`IsNever`, `IsNotNever`, `IsUnknown`, `IsNotUnknown`, `IsNotStrictFunction`
and `$SelectInvert` (behind `IsNotFunction` and `IsNotNumeric`) resolve a
`$void` branch, but their `$Options` never declared it. The strict option
keys rejected it, so the `void` branch could no longer be overridden.

The strict-key spec, snapshot probe, changeset and guide used
`IsNever<1, { $void: 'V'; $else: 'E' }>` as an invalid key; use
`distributive`, which `IsNever` does not take, and pin `$void` as accepted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S7D6GMwJiCoJGmdnWD9Q3T
…nteger predicates

`IsPositive`, `IsNegative`, `IsInteger` and their negations declared
`$any`, `$unknown`, `$never` and `$void` but passed only `distributive`
and their own branches to `IsBigint`/`IsNumber`, so the options compiled
and did nothing.

Route through `$Special` when one of those keys is passed, with the
previous body as the default for every branch. Without them the type takes
the previous body directly: +3 instantiations per use without options,
+8 to +10 with other options.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S7D6GMwJiCoJGmdnWD9Q3T
@unional
unional added this pull request to the merge queue Sep 14, 2026
Merged via the queue into main with commit fc00f7c Sep 14, 2026
7 checks passed
@unional
unional deleted the feat-strict-options branch September 14, 2026 21:55
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.

feat(type-plus): reject unknown option keys

1 participant