Skip to content

fix(schema): keep the NaN check when a union discriminates on typeof - #1702

Merged
ssalbdivad merged 2 commits into
arktypeio:mainfrom
thecotne:fix-union-number-nan
Oct 20, 2026
Merged

ssalbdivad merged 2 commits into
arktypeio:mainfrom
thecotne:fix-union-number-nan

Conversation

@thecotne

@thecotne thecotne commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #1700

What

Compiled unions discriminated by typeof, like number | null, number | string or { a: number } | { a: string }, now reject NaN in their number branch, matching number alone and the jitless path:

type("number | null")(Number.NaN) // now ArkErrors: must be a number (was NaN), was NaN

Why

pruneDiscriminant removed the number domain node from the branch matched by switch (typeof data), assuming typeof had already checked it. But that node also carries the NaN check, and typeof NaN is "number", so the "number" case compiled to return true.

When the discriminant is a domain, pruneDiscriminant now keeps a number domain node whose requiresNaNCheck is set. That property was private and is now public, so union.ts can read it. Unit discriminants still prune the domain, since a unit literal implies it. Domains with numberAllowsNaN: true are 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 of true.
  • match.test.ts, "accounts for ordering during discrimination": the { id: number } branch now keeps its id: number check. It no longer appears as an unreachable second branch after an earlier {} branch in the nested kind cases. That removal changes no behavior, because an ordered union always takes the earlier {} branch. Its default case now validates id as a number, so { id: NaN } is rejected there too.

Tests

In ark/type/__tests__/keywords/number.test.ts:

  • NaN is rejected by number | null, number | string, number > 0 | null and { a: number } | { a: string }, with JIT and with jitless: true, while valid values still pass.
  • A union with a numberAllowsNaN: true domain still accepts NaN.

Checklist:

  • Code is up-to-date with the main branch (based on c34bf69)
  • You've successfully run pnpm prChecks locally
  • There are new or updated unit tests validating the change

Overlap: #1703 (for #1598), which makes number reject Infinity, includes the same pruneDiscriminant change. 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 the pruneDiscriminant condition, and keep both sets of new tests in number.test.ts.

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

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.requiresNaNCheck is now public — visibility widened from private so union.ts can read it; no runtime change.
  • pruneDiscriminant keeps a number domain that requires a NaN check — typescriptType.constraint.subconstraint unions discriminated by typeof (e.g. number | null, number | string) now reject NaN, matching bare number and 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 and jitless for number | null, number | string, number > 0 | null, { a: number } | { a: string }, and a numberAllowsNaN union.

⚠️ 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.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

Comment thread ark/schema/roots/union.ts
Comment on lines +1090 to +1093
return (
discriminantCtx.kind === "unit" ||
!(node.hasKind("domain") && node.requiresNaNCheck)
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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>
@ssalbdivad
ssalbdivad merged commit c82c596 into arktypeio:main Oct 20, 2026
13 checks passed
@github-project-automation github-project-automation Bot moved this from To do to Done (merged or closed) in arktypeio Oct 20, 2026
@ssalbdivad

Copy link
Copy Markdown
Member

thanks @thecotne, nice catch! added a small follow-up so an explicit number.NaN branch still gets routed to the right case, then merged

ssalbdivad added a commit that referenced this pull request Oct 20, 2026
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>
ssalbdivad added a commit that referenced this pull request Oct 20, 2026
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>
ssalbdivad added a commit that referenced this pull request Oct 20, 2026
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.
ssalbdivad added a commit that referenced this pull request Oct 21, 2026
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>
ssalbdivad added a commit that referenced this pull request Oct 21, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done (merged or closed)

Development

Successfully merging this pull request may close these issues.

Unions discriminated by typeof accept NaN for number (e.g. number | null)

2 participants