Repository navigation
fix(schema): keep the NaN check when a union discriminates on typeof - #1702
Conversation
`number` rejects NaN, but when a union like `number | null` is discriminated by `typeof`, `pruneDiscriminant` removed the `number` domain node from the matched branch, assuming `typeof` had already checked it. `typeof NaN` is "number", so compiled unions accepted NaN while jitless validation rejected it. Keep number domain nodes that exclude NaN when pruning a domain discriminant. Unit discriminants still prune them, since a unit implies its domain.
There was a problem hiding this comment.
Important
The number | null fix is correct, but the same change makes unions that include an explicit number.NaN unit alongside a number domain reject NaN, while the jitless path still accepts it. type("number.NaN | number | null").allows(Number.NaN) flips from true on main to false here.
Reviewed changes
DomainNode.requiresNaNCheckis now public — visibility widened fromprivatesounion.tscan read it; no runtime change.pruneDiscriminantkeeps anumberdomain that requires a NaN check —typescriptType.constraint.subconstraintunions discriminated bytypeof(e.g.number | null,number | string) now rejectNaN, matching barenumberand the jitless path.- Snapshots updated —
discrimination.test.ts">2 domain branches" now keeps{ domain: "number" }for the"number"case;match.test.ts"accounts for ordering during discrimination" now retains the{ id: number }check. - New tests in
number.test.ts— cover JIT andjitlessfornumber | null,number | string,number > 0 | null,{ a: number } | { a: string }, and anumberAllowsNaNunion.
⚠️ Explicit number.NaN units are now dropped from discriminated unions
Unions where the discriminant resolves a number domain and a number.NaN unit into the same typeof "number" case lose NaN acceptance when the NaN unit ends up in the unreachable default case. Before this PR, the pruned-domain shortcut accidentally accepted NaN, so the missing branch was masked; now nothing accepts it. number.NaN | number | string (where the unit is grouped into the "number" case) still works, so the behavior is inconsistent across shapes.
I verified this against both main and the PR head with type.allows (JIT) and type.module(..., { jitless: true }); the inline comment has the repro and root cause.
Technical details
# `number.NaN` unit branch becomes unreachable in discriminated unions
## Affected sites
- `ark/schema/roots/union.ts:1090` — keeping the `number` node here removes the only code path that previously accepted `NaN` for these unions; the `NaN` unit is placed in the `default` case, which is never reached because `typeof NaN === "number"`.
- `ark/type/__tests__/keywords/number.test.ts:65` — the new union tests don't cover a union that contains an explicit `number.NaN` unit.
## Observed behavior (PR head)
```
type("number.NaN | number | null").allows(Number.NaN) // false (main: true; jitless: true)
type("number.NaN | number | undefined").allows(Number.NaN) // false (main: true; jitless: true)
type("number.NaN | number | string").allows(Number.NaN) // true
type("number.NaN | number").allows(Number.NaN) // true (indiscriminable)
```
`type("number.NaN")` alone allows `NaN`, and `number.NaN | number` is represented as `["number", { unit: "NaN" }]`, so a union that includes the `NaN` unit is expected to accept `NaN`.
`discriminantJson` for the failing shape:
```
{"kind":"domain","path":[],"cases":{
"\"number\"": {"domain":"number"},
"\"null\"": {"unit":null},
"default": {"unit":"NaN"}}}
```
For the passing `| string` shape the `"number"` case is `["number", { "unit": "NaN" }]`, i.e. the unit is folded in.
## Required outcome
- `NaN` is accepted whenever the union contains a branch that accepts it, including when that branch is an explicit `number.NaN` unit, independent of JIT vs jitless.
## Suggested approach (optional)
- Fold a `NaN` unit branch into the `"number"` case during resolution (`resolveCase` / discriminant candidate cases), since its `typeof` key collides with the `number` domain case.
- Alternatively, in `pruneDiscriminant` only keep the `number` node when no sibling in the same case already accepts `NaN`.
## Open questions for the human
- Is this in scope here, or tracked as a separate discriminant-resolution bug? The regression is caused by this PR even though the pre-existing acceptance was accidental.deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
| return ( | ||
| discriminantCtx.kind === "unit" || | ||
| !(node.hasKind("domain") && node.requiresNaNCheck) | ||
| ) |
There was a problem hiding this comment.
Keeping the number node is correct for number | null, but it regresses unions that also contain an explicit number.NaN unit: type("number.NaN | number | null").allows(Number.NaN) returns false here, versus true on main and true with jitless: true. In those unions the NaN unit is resolved into the unreachable default case (its typeof is "number"), so the pruned-domain shortcut this PR removes was the only branch accepting NaN, and now none does.
Technical details
# `number.NaN` unit branch becomes unreachable in discriminated unions
## Affected sites
- `ark/schema/roots/union.ts:1090` — this condition removes the only accepting path; the `NaN` unit sits in an unreachable `default` case.
- `ark/type/__tests__/keywords/number.test.ts:65` — add a case with an explicit `number.NaN` unit in the union.
## Repro (PR head)
```
type("number.NaN | number | null").allows(Number.NaN) // false (main true, jitless true)
type("number.NaN | number | undefined").allows(Number.NaN) // false (main true, jitless true)
type("number.NaN | number | string").allows(Number.NaN) // true (unit folded into "number" case)
```
`discriminantJson`: `{"kind":"domain","path":[],"cases":{"\"number\"":{"domain":"number"},"\"null\"":{"unit":null},"default":{"unit":"NaN"}}}`.
## Required outcome
- `NaN` accepted whenever the union has a branch accepting it, including an explicit `number.NaN` unit, matching `jitless`.
## Suggested approach (optional)
- Fold the `NaN` unit into the `"number"` case during resolution, or only keep the `number` node when no sibling in the same case accepts `NaN`.
## Open questions for the human
- In scope here, or a separate discriminant-resolution follow-up?With the number domain now kept in a typeof-discriminated branch, a
union like number.NaN | number | null routed NaN to the number case,
whose number domain excludes NaN, so the number.NaN branch landed in
the unreachable default case: JIT rejected NaN while jitless accepted
it. The same misroute already rejected { a: NaN } on main for
{ a: number.NaN | 'x' } | { a: object } | { a: 3 }.
The node for a number case now allows NaN, since a typeof check
doesn't exclude it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
thanks @thecotne, nice catch! added a small follow-up so an explicit |
Bumps all publishable packages and documents the changes merged since 2.2.8: - keep tuple elements around defaults in .in and .out (#1696, @ybai08) - reject NaN in unions discriminated by typeof (#1702, @thecotne) - don't treat Object.prototype keys as declared (#1701, @thecotne) - only emit referenced $defs in toJsonSchema (#1689, fixes #1612, #1511 and #1546, @giaBaoJS) - convert unrepresentable types in ~standard.jsonSchema instead of throwing (#1694) - arkregex: allow } to be escaped (#1691, @cpruijsen) - attest: onlyNamedTestFiles (#1688) Also moves attest to TypeScript 7.1's stable native API. Starting with 7.1.0-dev.20261009.1, typescript/unstable/{ast,sync,fs} are exported as typescript/{ast,sync,fs}, so the published 0.57.0 fails on every current 7.1 build with "Package subpath './unstable/ast' is not defined by exports". The tsconfig paths aliases for the old specifiers kept resolving them, so typecheck stayed green while attest crashed at runtime. They are renamed alongside the imports. The workspace TypeScript moves to 7.1.0-dev.20261020.3, the latest nightly (no 7.1 beta is published yet). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Bumps all publishable packages and documents the changes merged since 2.2.8: - keep tuple elements around defaults in .in and .out (#1696, @ybai08) - reject NaN in unions discriminated by typeof (#1702, @thecotne) - don't treat Object.prototype keys as declared (#1701, @thecotne) - only emit referenced $defs in toJsonSchema (#1689, fixes #1612, #1511 and #1546, @giaBaoJS) - convert unrepresentable types in ~standard.jsonSchema instead of throwing (#1694) - arkregex: allow } to be escaped (#1691, @cpruijsen) - attest: onlyNamedTestFiles (#1688) Also moves attest to TypeScript 7.1's native API paths. Starting with 7.1.0-dev.20261009.1, typescript/unstable/{ast,sync,fs} are exported as typescript/{ast,sync,fs}, so the published 0.57.0 fails on every current 7.1 build with "Package subpath './unstable/ast' is not defined by exports". The tsconfig paths aliases for the old specifiers kept resolving them, so typecheck stayed green while attest crashed at runtime. They are renamed alongside the imports. The workspace TypeScript moves to 7.1.0-beta, which exports the same paths. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Bumps all publishable packages and documents the changes merged since 2.2.8: - keep tuple elements around defaults in .in and .out (#1696, @ybai08) - reject NaN in unions discriminated by typeof (#1702, @thecotne) - don't treat Object.prototype keys as declared (#1701, @thecotne) - only emit referenced $defs in toJsonSchema (#1689, fixes #1612, #1511 and #1546, @giaBaoJS) - convert unrepresentable types in ~standard.jsonSchema instead of throwing (#1694) - arkregex: allow } to be escaped (#1691, @cpruijsen) - attest: onlyNamedTestFiles (#1688) Also moves attest to TypeScript 7.1's native API paths. Starting with 7.1.0-dev.20261009.1, typescript/unstable/{ast,sync,fs} are exported as typescript/{ast,sync,fs}, so the published 0.57.0 fails on every current 7.1 build with "Package subpath './unstable/ast' is not defined by exports". The tsconfig paths aliases for the old specifiers kept resolving them, so typecheck stayed green while attest crashed at runtime. They are renamed alongside the imports. The workspace TypeScript moves to 7.1.0-beta, which exports the same paths.
Ports the tests of c82c596 from arktype 2.2.9 (#1702, @thecotne). There, `number | null` accepted NaN once compiled, as pruning a typeof discriminant dropped the number domain's NaN check, and a number case excluded NaN, misrouting `number.NaN | number | null`. This branch already behaves as 2.2.9 does: arksets keeps a number domain's NaN and Infinity checks under a typeof discriminant, and 35cbdb0 lets a number case allow both. So only the tests come over. Jitless validation now writes the same errors as compiled validation, so the jitless test expects `must be a number (was NaN)` and `a must be a number (was NaN)` where 2.2.9 wrote `must be a number or null` and `a must be a number or a string`. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
main contributes the 2.2.9 release: version bumps for every publishable
package (arktype 2.2.9, @ark/schema, @ark/util and @ark/fs 0.56.8, attest
0.57.1, arkregex 0.0.14, @ark/json-schema 0.0.13, @ark/fast-check 0.0.20),
attest's move to TypeScript 7.1's native API paths (typescript/{ast,sync,fs}
in place of typescript/unstable/*, with the workspace catalog on
7.1.0-beta), arkdark's regex() highlighting and CI/icon changes, and the
2.2.9 fixes: tuple elements around defaults in .in/.out (#1696), the NaN
check in typeof-discriminated unions (#1702), Object.prototype keys as
undeclared (#1701), only referenced $defs in toJsonSchema (#1689) and
escaped } in arkregex (#1691).
featherduster already had an equivalent of every one of those fixes, each
with main's test or a broader one, so every code conflict keeps
featherduster's side:
- ark/schema constraint.ts, roots/{domain,root,union}.ts,
structure/{sequence,structure}.ts, shared/toJsonSchema.ts: main patched
code that featherduster has moved to ark/sets (intersectConstraints,
discrimination, toJsonSchema) or rewritten. featherduster already narrows
two constraints with one bound, reduces tuple prefix/optionals around
defaultables, keeps the NaN check on a typeof case, uses a null-prototype
propsByKey and resolves only referenced $defs.
- ark/type/__tests__/discrimination.test.ts: main's "number case includes
NaN" is covered by featherduster's "number case includes NaN and
Infinity". Its jitless loop is dropped, since testJitless runs the whole
suite jitless.
- ark/type/__tests__/keywords/number.test.ts: featherduster's messages
("must be a number (was NaN)") are kept. A miss of number | null has the
discriminant code there, so it names only the branch the value reached.
- ark/type/__tests__/match.test.ts: featherduster's caseReference snapshots.
- ark/type/__tests__/toJsonSchema.test.ts: featherduster's referencedDefs
tests cover main's three useRefs cases (#1546, #1511, #1612).
- CHANGELOGs: featherduster already carried arktype's 2.2.9 and arkregex's
0.0.14 sections, identical to main's, below its Unreleased sections. In
attest's, main's 0.57.1 section goes below Unreleased.
- pnpm-lock.yaml: featherduster's, reinstalled against the merged
catalog, so the only change is typescript 7.1.0-dev.20261006.1 ->
7.1.0-beta.
The precompiled-module version link moves with the version: runtime.ts
exports precompiledForArktype_2_2_9 in place of precompiledForArktype_2_2_8,
which build.ts's checkVersionExport derives from package.json. The
precompile setup and troubleshooting docs, and llms-full.txt generated
from them, name 2_2_9 and "runs on 2.2.9" in their examples. The docs'
generated util.ts declarations pick up @ark/util 0.56.8.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Fixes #1700
What
Compiled unions discriminated by
typeof, likenumber | null,number | stringor{ a: number } | { a: string }, now reject NaN in theirnumberbranch, matchingnumberalone and the jitless path:Why
pruneDiscriminantremoved thenumberdomain node from the branch matched byswitch (typeof data), assumingtypeofhad already checked it. But that node also carries the NaN check, andtypeof NaNis"number", so the"number"case compiled toreturn true.When the discriminant is a domain,
pruneDiscriminantnow keeps anumberdomain node whoserequiresNaNCheckis set. That property was private and is now public, sounion.tscan read it. Unit discriminants still prune the domain, since a unit literal implies it. Domains withnumberAllowsNaN: trueare still pruned as before.Two discriminant snapshots change as a result:
discrimination.test.ts, ">2 domain branches": the"number"case is now{ domain: "number" }instead oftrue.match.test.ts, "accounts for ordering during discrimination": the{ id: number }branch now keeps itsid: numbercheck. It no longer appears as an unreachable second branch after an earlier{}branch in the nestedkindcases. That removal changes no behavior, because an ordered union always takes the earlier{}branch. Itsdefaultcase now validatesidas a number, so{ id: NaN }is rejected there too.Tests
In
ark/type/__tests__/keywords/number.test.ts:number | null,number | string,number > 0 | nulland{ a: number } | { a: string }, with JIT and withjitless: true, while valid values still pass.numberAllowsNaN: truedomain still accepts NaN.Checklist:
mainbranch (based on c34bf69)pnpm prCheckslocallyOverlap: #1703 (for #1598), which makes
numberrejectInfinity, includes the samepruneDiscriminantchange. If that lands first, it covers this bug for the default config. The two changes touch the same lines, so whichever merges second needs a small rebase: keep the Infinity version of thepruneDiscriminantcondition, and keep both sets of new tests innumber.test.ts.