feat(type-plus)!: honor exact on the numeric sign and integer predicates - #696
Conversation
…cates
`IsPositive`, `IsNegative`, `IsInteger`, `IsNotPositive`, `IsNotNegative` and
`IsNotInteger` declared `exact` and never read it. The option compiled and did
nothing, and no spec covered it.
It now follows the string family, where `exact` separates the wide type from its
literals: with `exact: true` only the wide `number` and `bigint` match, and every
literal answers the `$else` branch. The sign of a wide type is unknown, so it
stays `boolean`; `bigint` is still an integer, so `IsInteger<bigint>` stays
`true`.
The other half of the split is new — `IsPositiveLiteral`, `IsNegativeLiteral`,
`IsIntegerLiteral` and their `IsNot*` forms match only literals, the way
`IsNumberLiteral` does for `IsNumber`. They do not declare `exact`: numbers have
no analog of the template literal types `exact` excludes on strings, so it would
be another option that compiles and does nothing.
For the same reason `exact` is removed from `IsNumberLiteral`,
`IsNotNumberLiteral`, `IsBigintLiteral`, `IsNotBigintLiteral` and `IsNumeric`,
none of which ever read it.
Calls that pass no `exact` answer exactly as before. The `exact` dispatch costs
about 4 more instantiations per use on that path (`IsPositive` 177.9 -> 181.9 on
TS 6.0); the `exact: true` and `*Literal` paths cost about half a plain call.
BREAKING CHANGE: `{ exact: true }` on the six sign and integer predicates used to
be ignored and now changes the answer. `exact` on `IsNumberLiteral`,
`IsNotNumberLiteral`, `IsBigintLiteral`, `IsNotBigintLiteral` and `IsNumeric` is
now a compile error; dropping it leaves the result unchanged.
Closes #695
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 16b266e The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #696 +/- ##
=========================================
Coverage 100.00% 100.00%
=========================================
Files 43 43
Lines 231 231
Branches 51 51
=========================================
Hits 231 231 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The readme still described the v7 surface. Audited every name in it against the 281 exports of `src/index.ts` and every relative link against the filesystem. Removed 26 types that no longer exist — the `*Type` parse-style names (`AnyType`, `StringType`, `BooleanType`, `NumberType`, `BigintType`, `StrictNumberType`, `Integer`, `Positive`, `Negative`, `IsWhole` and the rest) — and replaced the sections that listed them with the types the package actually exports, each linked to its source file. Also fixed: - 7 dead `./src/...` links and the `ts/string`, `ts/symbol` and `./README.md` paths, none of which resolved. - `Equal` was marked deprecated in favour of `IsEqual`; it is the other way round. `CanAssign`, `StrictCanAssign`, `Extendable`, `NotExtendable`, `IsExtend`, `IsNotExtend`, `PromiseValue`, `isConstructor`, `unpartial` and `drop` are deprecated and were not marked; each now carries the replacement named in its own `@deprecated` tag. - `KeyofOptional` and `KeysWithDiffTypes` were misspelled as types that do not exist (`KeysOfOptional`, `KeysWithDiffType`). - v7 `<T, Then, Else>` signatures on `Equal`, `If`, `And`, `Or`, `Xor`, `Not` and the number, bigint and numeric predicates, all of which take `$Options` now. - `MapToProp` and `PropUnion` described as distinct types; both are aliases. - The tag legend lists 🎭 / 🌪️ / 🦴, while the body used ⭕ / ↪️ / 🔨, which the legend never defines. Normalized the body to the legend's vocabulary. The numeric section also documents the six `*Literal` types this branch adds and the corrected `exact` semantics. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Added: the package readme brought up to v8
26 types that no longer exist, removed along with the sections built around them: the Links: 7 dead Deprecations were inverted or missing. Misspellings: v7 signatures: Wrong descriptions: Tag vocabulary: the legend in the same file lists 🎭 / 🌪️ / 🦴, while 111 entries in the body The numeric section also picks up the six new Audit is reproducible: no linked name is absent from the export surface, no relative link fails to 🤖 Generated with Claude Code |
Closes #695
IsPositive,IsNegative,IsInteger,IsNotPositive,IsNotNegativeandIsNotIntegerdeclared
exactand never read it, whileapi/number.mdlisted it as supported. This makes theoption real, adds the literal half of the split, and removes
exactwhere it was dead.The open questions, and how they were settled
What does
IsPositive<number, { exact: true }>answer?boolean. Decided on the issue: thesign of the wide
numberis unknown, so the predicate cannot commit. The same holds forbigintand for the other five.
IsIntegeris the one place the wide answers differ, and only becausethey already did: every
bigintis an integer, soIsInteger<bigint, { exact: true }>istrueand
IsNotInteger<bigint, { exact: true }>isfalse.exact: trueon the six matches only the widenumberandbigint, never a literal, the wayIsNumber<T, { exact: true }>rejects every number literal. An intersection is classified by itsnumeric constituent, so
number & { a: 1 }is wide and1 & { a: 1 }is a literal — the same rulethe predicates already applied without
exact.Each
IsNot*stays the exact negation of its positive form underexact, matchingIsNotString/IsString.Do the literal variants take
exact? No, as decided on the issue. OnIsStringLiteral,exactexcludes template literal types such as
`${number}`that do not reduce to a single literal.The issue asked to confirm there is no numeric middle case before removing it; there is not.
A numeric enum member (
E.a), the enum union (E), and a literal intersected with a record(
1 & { a: 1 }) all classify as literals either way — checked against the compiler, not justreasoned about. So
exactwould be another option that compiles and does nothing, and with$StrictOptionsfrom #694,IsPositiveLiteral<1, { exact: true }>is rejected.IsNumberLiteral,IsBigintLiteralandIsNumericdeclareexactand ignore it. Fixed hererather than deferred: the analogy settles it, and leaving them would contradict the semantics this
PR gives
exacteverywhere else. None of the three reads$O['exact'], so the result withexact: trueis by construction the result without it, which is the condition the issue set forremoval. Two more were found in the same pass and removed for the same reason:
IsNotNumberLiteralandIsNotBigintLiteral. Leaving those two would meanIsNumberLiteralrejects
exactwhileIsNotNumberLiteralstill accepts it and ignores it — the same defect, in thesame family, in the same file pair.
What changed
IsPositive,IsNegative,IsInteger,IsNotPositive,IsNotNegative,IsNotIntegerhonorexact. TheX._split and the special-branch handling from feat(type-plus)!: reject unknown option keys #694 are intact; theexactdispatch sits at the head of
X._, and the previous body is the$else.numeric/_numeric_exact.tscarries the sharedexact: truepath. The six differ only inwhich of
'then' | 'else' | 'both'they answer for the widenumber, the widebigint, andeverything else.
IsPositiveLiteral,IsNegativeLiteral,IsIntegerLiteral,IsNotPositiveLiteral,IsNotNegativeLiteral,IsNotIntegerLiteral. Exported from the root,NumericPlusandNumberPlus.numeric/_numeric_fraction.tsreads a fractional part off a numeric literal through_BareIntersection, so1.1 & { a: 1 }is classified like1.1.exactremoved fromIsNumberLiteral,IsNotNumberLiteral,IsBigintLiteral,IsNotBigintLiteral,IsNumeric.api/number.md: the blanket "all theIs*types acceptexact" claim was wrong and is nowscoped;
exactsections for the integer and sign predicates; a section for the six new types;reference rows.
api/math.mdnotes that the bigint literal predicates do not takeexact.The legacy
src/<family>/readme.mdtree was grepped:numeric/has no readme, and thenumber/and
bigint/ones documentexactonly onIsNumberandIsBigint, which are unchanged.'exact' is not a valid option" text. No existing snapshotline moved.
@examplepinned by atestType.equalin thatsymbol's own spec.
Specs
Per type:
exact: trueandexact: falseover literals, the widenumber/bigint,any/unknown/never/void, non-numeric types, unions, intersections, and combined withdistributiveandselection: 'filter', plus$Branchand the special-branch overrides.The
exact: falseblock asserts each call is identical to the same call with noexactat all,which is the guarantee that results did not change:
The six literal types each get a full spec, including a
@ts-expect-errorthatexactis rejected.Instantiations per use
Measured with
pnpm --filter type-plus bench:instantiations; the new types and the+exactuseswere added to the script.
IsPositiveIsNegativeIsIntegerIsNotPositiveIsNotNegativeIsNotIntegerThe default path costs 3 to 4 more instantiations per use — the
$ResolveOptionscheck onexact.An earlier draft used
$Exact.Parse, which cost 12; the object literal and indexed access it buildsare what the
$ResolveOptionsform avoids. A'exact' extends keyof $Oguard was measured too andsaved nothing on the default path while costing 4 on the
exactpath, so it was dropped.New, and cheaper than a plain call because they stop at the literal test:
IsPositive+exactIsNotInteger+exactIsPositiveLiteralIsNotIntegerLiteralVerification
pnpm install && pnpm verify:pkggreen:test:typeon 5.4, 5.5, 5.6, 6.0 and 7,test:errorsonall five,
build,verify:dts,coverage(2640 tests),size, and the website build.pnpm docs:llms:checkup to date.biome checkclean overpackages,appsand.changeset.pnpm knipnot run — pre-existing per AGENTS.md.Changeset:
major, naming which changes are breaking.🤖 Generated with Claude Code