Skip to content

fix(type-plus)!: accept interfaces and readonly data as JSON, reject undefined - #822

Merged
unional merged 2 commits into
mainfrom
fix-json-types
Oct 11, 2026
Merged

unional merged 2 commits into
mainfrom
fix-json-types

Conversation

@unional

@unional unional commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes four defects in the JSON types before 8.0 and adds IsJson (item 7 of the approved list).

What changes

Defect Fix
An interface is not assignable to JsonTypes New JsonCompatible<T>: constrain with T extends JsonCompatible<T>. New IsJson<T> / IsNotJson<T> predicates.
Readonly data (as const) is rejected JsonArray is now readonly JsonTypes[]
{ a: undefined } is accepted as JsonTypes JsonObject is now { [key: string]: JsonTypes } (the old one was a mapped type with ?, which adds undefined to the index signature)
JsonTypes.get is dead Removed, together with the JsonTypes and JSONTypes values. The deprecated JSONTypes type alias stays until 9.0.

JsonTypes still means "any value JSON.parse can produce".

Design: accepting interfaces

TypeScript never lets an interface satisfy an index signature, so widening JsonTypes cannot fix this. JsonCompatible<T> is a homomorphic mapped type over T's own keys. Each value is either kept, because it is already JSON, or replaced by the string literal `not JSON at ${path}`. That gives:

  • T extends JsonCompatible<T> works as a constraint. The top level is a mapped type rather than a conditional, so it is not a circular constraint (TS2313). The & (JsonPrimitive | (object & { [Symbol.hasInstance]?: never })) part closes the top-level gaps a mapped type leaves: bigint, undefined, symbol and functions.
  • The error names the path, on all five compilers:
    Type 'Date' is not assignable to type '"not JSON at $.author.profile.createdAt"'. Array elements show as [*] and tuple elements by index.
  • A value already assignable to JsonTypes is kept as is, which also stops the recursion on JsonTypes itself (without that, TS2589).

IsJson<T> is T extends JsonCompatible<T> in the $ branching style (selection, distributive, $Branch, $Fn, special-type branches), shaped like IsBigint. IsNotJson mirrors it.

Rejected alternatives

  • Path errors in $Error: the $Type brand prints the message twice, along with [_$type], [_$value] noise. The plain string literal reads cleanly.
  • Top-level conditional (T extends JsonPrimitive ? … : …): TS2313 circular constraint under T extends JsonCompatible<T>. It only works in the parameter form v: T & JsonCompatible<T>, which is less idiomatic.
  • { call?: never } as the function guard: it would also reject a plain object with a call key. Symbol.hasInstance does not have that problem.
  • IsJson alone, without JsonCompatible: a predicate cannot be inferred through in a parameter position, so it cannot serve as the constraint.

Design: undefined versus optional properties

Under the old JsonObject both forms were accepted. Now:

  • { a: undefined } and { a: number | undefined } are rejected, by JsonTypes and by JsonCompatible.
  • { a?: number } is accepted. JSON.stringify drops a missing key, so it comes back from JSON.parse missing in the same way, and optional fields are common in JSON-shaped models. All five compilers relate an optional property to a non-optional index signature this way. A probe confirmed it, and the specs pin it.

The trade-off: TypeScript has no index signature that rejects undefined on write and adds it on read. With the new JsonObject, o['a'] reads as JsonTypes | undefined under noUncheckedIndexedAccess, which is TypeScript's switch for exactly this, and as JsonTypes without it. Before this PR the read was always | undefined, but the cost was letting undefined in, which the TSDoc claimed was rejected. I chose the strict write side because JSON.parse never produces an undefined value, so the new type describes parsed data accurately. The TSDoc states the read behavior.

Tests

  • testType specs in json.spec.ts, json-compatible.spec.ts, is-json.spec.ts and is-not-json.spec.ts cover interfaces, recursive interfaces, as const, { a: undefined }, { a?: number }, top-level non-JSON values and path text. They pass on TS 5.4, 5.5, 5.6, 6.0 and 7. Specs now import from the package entries.
  • Error-snapshot probes cover JsonTypes against undefined and an interface, and JsonCompatible against an interface (no error), a nested path, an undefined path, an array path and a top-level bigint. One snapshot is shared by all five compilers.

Docs

Rebased onto main after #820. api/json.md documents the new JsonObject and JsonArray behavior, JsonCompatible, IsJson and IsNotJson. The v8 migration guide gains a "JSON types" changed-results section, a JsonTypes.get() row under removed functions, and a "New in v8" line. generate-llms-txt.mjs maps is-json.ts, is-not-json.ts and json-compatible.ts to api/json, and llms.txt is regenerated. packages/type-plus/readme.md drops the JsonTypes.get text.

pnpm verify, test:type (TS 5.4, 5.5, 5.6, 6.0 and 7) and test:errors pass locally.

🤖 Generated with Claude Code

@changeset-bot

changeset-bot Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 01b70c4

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

unional and others added 2 commits October 10, 2026 18:50
…undefined

- JsonArray is readonly; JsonObject is a plain index signature, so a
  property holding undefined is rejected while optional properties pass.
- Add JsonCompatible<T>, a self-referencing constraint that accepts
  interfaces and names the path of the part that is not JSON.
- Add IsJson and IsNotJson predicates in the $ branching style.
- Remove the unreachable JsonTypes.get and the JsonTypes/JSONTypes values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Oct 11, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (fd6ae1b) to head (01b70c4).

Additional details and impacted files
@@            Coverage Diff            @@
##              main      #822   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           40        39    -1     
  Lines          182       175    -7     
  Branches        35        33    -2     
=========================================
- Hits           182       175    -7     

☔ 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
unional added this pull request to the merge queue Oct 11, 2026
Merged via the queue into main with commit 36cf13b Oct 11, 2026
7 checks passed
@unional
unional deleted the fix-json-types branch October 11, 2026 01:56
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.

1 participant