Skip to content

feat(type-plus)!: honor exact on the numeric sign and integer predicates - #696

Merged
unional merged 2 commits into
mainfrom
feat-numeric-exact-literals-695
Sep 16, 2026
Merged

unional merged 2 commits into
mainfrom
feat-numeric-exact-literals-695

Conversation

@unional

@unional unional commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

Closes #695

IsPositive, IsNegative, IsInteger, IsNotPositive, IsNotNegative and IsNotInteger
declared exact and never read it, while api/number.md listed it as supported. This makes the
option real, adds the literal half of the split, and removes exact where it was dead.

The open questions, and how they were settled

What does IsPositive<number, { exact: true }> answer? boolean. Decided on the issue: the
sign of the wide number is unknown, so the predicate cannot commit. The same holds for bigint
and for the other five. IsInteger is the one place the wide answers differ, and only because
they already did: every bigint is an integer, so IsInteger<bigint, { exact: true }> is true
and IsNotInteger<bigint, { exact: true }> is false.

exact: true on the six matches only the wide number and bigint, never a literal, the way
IsNumber<T, { exact: true }> rejects every number literal. An intersection is classified by its
numeric constituent, so number & { a: 1 } is wide and 1 & { a: 1 } is a literal — the same rule
the predicates already applied without exact.

Each IsNot* stays the exact negation of its positive form under exact, matching
IsNotString/IsString.

Do the literal variants take exact? No, as decided on the issue. On IsStringLiteral, exact
excludes 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 just
reasoned about. So exact would be another option that compiles and does nothing, and with
$StrictOptions from #694, IsPositiveLiteral<1, { exact: true }> is rejected.

IsNumberLiteral, IsBigintLiteral and IsNumeric declare exact and ignore it. Fixed here
rather than deferred: the analogy settles it, and leaving them would contradict the semantics this
PR gives exact everywhere else. None of the three reads $O['exact'], so the result with
exact: true is by construction the result without it, which is the condition the issue set for
removal. Two more were found in the same pass and removed for the same reason:
IsNotNumberLiteral and IsNotBigintLiteral. Leaving those two would mean IsNumberLiteral
rejects exact while IsNotNumberLiteral still accepts it and ignores it — the same defect, in the
same family, in the same file pair.

What changed

  • IsPositive, IsNegative, IsInteger, IsNotPositive, IsNotNegative, IsNotInteger honor
    exact. The X._ split and the special-branch handling from feat(type-plus)!: reject unknown option keys #694 are intact; the exact
    dispatch sits at the head of X._, and the previous body is the $else.
  • New numeric/_numeric_exact.ts carries the shared exact: true path. The six differ only in
    which of 'then' | 'else' | 'both' they answer for the wide number, the wide bigint, and
    everything else.
  • New: IsPositiveLiteral, IsNegativeLiteral, IsIntegerLiteral, IsNotPositiveLiteral,
    IsNotNegativeLiteral, IsNotIntegerLiteral. Exported from the root, NumericPlus and
    NumberPlus.
  • New numeric/_numeric_fraction.ts reads a fractional part off a numeric literal through
    _BareIntersection, so 1.1 & { a: 1 } is classified like 1.1.
  • exact removed from IsNumberLiteral, IsNotNumberLiteral, IsBigintLiteral,
    IsNotBigintLiteral, IsNumeric.
  • api/number.md: the blanket "all the Is* types accept exact" claim was wrong and is now
    scoped; exact sections for the integer and sign predicates; a section for the six new types;
    reference rows. api/math.md notes that the bigint literal predicates do not take exact.
    The legacy src/<family>/readme.md tree was grepped: numeric/ has no readme, and the number/
    and bigint/ ones document exact only on IsNumber and IsBigint, which are unchanged.
  • Two error-snapshot probes pin the new "'exact' is not a valid option" text. No existing snapshot
    line moved.
  • TSDoc on every new and changed symbol, with each @example pinned by a testType.equal in that
    symbol's own spec.

Specs

Per type: exact: true and exact: false over literals, the wide number/bigint,
any/unknown/never/void, non-numeric types, unions, intersections, and combined with
distributive and selection: 'filter', plus $Branch and the special-branch overrides.

The exact: false block asserts each call is identical to the same call with no exact at all,
which is the guarantee that results did not change:

testType.equal<IsPositive<1, { exact: false }>, IsPositive<1>>(true)

The six literal types each get a full spec, including a @ts-expect-error that exact is rejected.

Instantiations per use

Measured with pnpm --filter type-plus bench:instantiations; the new types and the +exact uses
were added to the script.

Type TS 6.0 before TS 6.0 after TS 7 before TS 7 after
IsPositive 177.9 181.9 180.1 183.9
IsNegative 176.2 180.2 178.4 182.2
IsInteger 145.2 149.2 146.4 150.1
IsNotPositive 202.4 205.4 204.6 207.4
IsNotNegative 191.7 194.7 193.9 196.7
IsNotInteger 169.7 172.7 171.0 173.6

The default path costs 3 to 4 more instantiations per use — the $ResolveOptions check on exact.
An earlier draft used $Exact.Parse, which cost 12; the object literal and indexed access it builds
are what the $ResolveOptions form avoids. A 'exact' extends keyof $O guard was measured too and
saved nothing on the default path while costing 4 on the exact path, so it was dropped.

New, and cheaper than a plain call because they stop at the literal test:

Type TS 6.0 TS 7
IsPositive+exact 82.9 83.4
IsNotInteger+exact 84.1 84.6
IsPositiveLiteral 84.1 85.4
IsNotIntegerLiteral 84.6 85.9

Verification

pnpm install && pnpm verify:pkg green: test:type on 5.4, 5.5, 5.6, 6.0 and 7, test:errors on
all five, build, verify:dts, coverage (2640 tests), size, and the website build.
pnpm docs:llms:check up to date. biome check clean over packages, apps and .changeset.
pnpm knip not run — pre-existing per AGENTS.md.

Changeset: major, naming which changes are breaking.

🤖 Generated with Claude Code

…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-bot

changeset-bot Bot commented Sep 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 16b266e

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 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (fc00f7c) to head (16b266e).

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.
📢 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.

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>
@unional

unional commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

Added: the package readme brought up to v8

packages/type-plus/readme.md still described the v7 surface. I audited every name in it against
the 281 exports of src/index.ts and every relative link against the filesystem, then fixed what
the audit turned up.

26 types that no longer exist, removed along with the sections built around them: the *Type
parse-style names (AnyType, ArrayType, BooleanType, FunctionType, NeverType, NullType,
StringType, SymbolType, TupleType, UndefinedType, UnknownType, VoidType, NumberType,
BigintType and their Not* forms), StrictNumberType / StrictBigintType / IsStrictNumber /
IsStrictBigint, and Integer, NotInteger, IsWhole, Positive, NotPositive, Negative,
NonNegative, IsNonNegative. Each section now lists what the package actually exports, linked to
its source file.

Links: 7 dead ./src/... targets, plus ts/string/readme.md, ts/symbol/readme.md and
./README.md (the file is readme.md) — none resolved. Every relative link in the file now does.

Deprecations were inverted or missing. Equal was marked deprecated in favour of IsEqual; the
@deprecated tags say the opposite. CanAssign, StrictCanAssign, Extendable, NotExtendable,
IsExtend, IsNotExtend, PromiseValue, isConstructor, unpartial and drop are all
deprecated and none was marked. Each now carries the replacement named in its own tag — e.g.
CanAssignAssignable<A, B>, StrictCanAssignAssignable<A, B, { distributive: false }>.

Misspellings: KeyofOptional and KeysWithDiffTypes were written as names that do not exist
(KeysOfOptional, KeysWithDiffType).

v7 signatures: Equal, If, And, Or, Xor, Not and the number/bigint/numeric predicates
were spelled <T, Then, Else>; they take $Options now. The families that genuinely still take
Then/Else (IsEqual, Extendable, CanAssign) keep it.

Wrong descriptions: MapToProp and PropUnion were described as distinct types; both are
plain aliases (MapToProp = IntersectOfProps, PropUnion = UnionOfProps).

Tag vocabulary: the legend in the same file lists 🎭 / 🌪️ / 🦴, while 111 entries in the body
used ⭕ / ↪️ / 🔨 / 📘, which the legend never defines. Normalized the body to the legend.

The numeric section also picks up the six new *Literal types and the corrected exact semantics
from this PR.

Audit is reproducible: no linked name is absent from the export surface, no relative link fails to
resolve, every deprecated export is marked, and every tag in use is in the legend.

🤖 Generated with Claude Code

@unional
unional added this pull request to the merge queue Sep 16, 2026
Merged via the queue into main with commit f1375b2 Sep 16, 2026
7 checks passed
@unional
unional deleted the feat-numeric-exact-literals-695 branch September 16, 2026 08:30
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): honor exact and add literal variants for the numeric sign and integer predicates

1 participant