From 8c1d53f758b7dc660bb796bc9d4c7d39e70ba501 Mon Sep 17 00:00:00 2001 From: unional Date: Sat, 10 Oct 2026 18:38:34 -0700 Subject: [PATCH 1/2] fix(type-plus)!: accept interfaces and readonly data as JSON, reject undefined - JsonArray is readonly; JsonObject is a plain index signature, so a property holding undefined is rejected while optional properties pass. - Add JsonCompatible, 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 --- .changeset/fix-json-types.md | 15 ++ packages/type-plus/error-snapshots/probes.ts | 34 ++++ .../type-plus/error-snapshots/snapshot.txt | 33 ++++ packages/type-plus/readme.md | 22 ++- packages/type-plus/src/index.ts | 14 +- packages/type-plus/src/is-json.spec.ts | 152 ++++++++++++++++++ packages/type-plus/src/is-json.ts | 136 ++++++++++++++++ packages/type-plus/src/is-not-json.spec.ts | 94 +++++++++++ packages/type-plus/src/is-not-json.ts | 132 +++++++++++++++ .../type-plus/src/json-compatible.spec.ts | 110 +++++++++++++ packages/type-plus/src/json-compatible.ts | 87 ++++++++++ packages/type-plus/src/json.spec.ts | 146 +++++++++-------- packages/type-plus/src/json.ts | 83 ++++------ 13 files changed, 923 insertions(+), 135 deletions(-) create mode 100644 .changeset/fix-json-types.md create mode 100644 packages/type-plus/src/is-json.spec.ts create mode 100644 packages/type-plus/src/is-json.ts create mode 100644 packages/type-plus/src/is-not-json.spec.ts create mode 100644 packages/type-plus/src/is-not-json.ts create mode 100644 packages/type-plus/src/json-compatible.spec.ts create mode 100644 packages/type-plus/src/json-compatible.ts diff --git a/.changeset/fix-json-types.md b/.changeset/fix-json-types.md new file mode 100644 index 0000000000..4fe7cc5920 --- /dev/null +++ b/.changeset/fix-json-types.md @@ -0,0 +1,15 @@ +--- +"type-plus": major +--- + +Fix the JSON types and add `JsonCompatible`, `IsJson` and `IsNotJson`. + +- `JsonArray` is readonly, so readonly arrays, tuples and `as const` data are accepted as `JsonTypes`. +- `JsonObject` rejects a property holding `undefined` (`{ a: undefined }`). An optional property (`{ a?: number }`) is still accepted. A read from it is `JsonTypes | undefined` under `noUncheckedIndexedAccess`, and `JsonTypes` without it. +- Add `JsonCompatible`: constrain with `T extends JsonCompatible` to accept any JSON-safe value, including an `interface`, which TypeScript never lets satisfy `JsonTypes`. The compiler error names the path of the part that is not JSON, such as `"not JSON at $.profile.createdAt"`. +- Add the `IsJson` and `IsNotJson` predicates, with the usual `selection`, `distributive` and branch options. +- Remove `JsonTypes.get` and the `JsonTypes` and `JSONTypes` values. `type-plus` only ever exported them as types, so they were unreachable. The deprecated `JSONTypes` type alias stays until 9.0. + +Migration: +- Replace `JsonTypes.get(obj, 'a', 'b')` with a plain property read and a cast. +- Replace a `T extends JsonTypes` constraint that has to accept interfaces with `T extends JsonCompatible`. diff --git a/packages/type-plus/error-snapshots/probes.ts b/packages/type-plus/error-snapshots/probes.ts index 7242564056..05dffc1fba 100644 --- a/packages/type-plus/error-snapshots/probes.ts +++ b/packages/type-plus/error-snapshots/probes.ts @@ -90,6 +90,8 @@ import type { IsUnion, IsUnknown, IsVoid, + JsonCompatible, + JsonTypes, Not, NotAssignable, Or, @@ -470,3 +472,35 @@ export const testType_canAssign_union_passes = tt.canAssign(true) export const testType_of_returns_equal_fails = tt.of((): number => 1).returns.equal(true) export const testType_of_constructibleWith_fails = tt.of(Date).constructibleWith<[boolean, boolean, boolean]>(true) + +// JSON: `JsonTypes` rejects an `undefined` property value. +export const json_types_undefined_property: JsonTypes = { a: undefined } + +// JSON: `JsonTypes` rejects an interface, which has no implicit index signature. +interface ProbeJsonUser { + id: number +} +declare const probe_json_user: ProbeJsonUser +export const json_types_interface: JsonTypes = probe_json_user + +// JSON: `JsonCompatible` accepts that interface. Must compile (no error). +declare function probe_json_save>(value: T): T +export const json_compatible_interface = probe_json_save(probe_json_user) + +// JSON: `JsonCompatible` names the path of a nested value that is not JSON. +interface ProbeJsonPost { + author: { profile: { createdAt: Date } } +} +declare const probe_json_post: ProbeJsonPost +export const json_compatible_nested_path = probe_json_save(probe_json_post) + +// JSON: `JsonCompatible` names the path of an `undefined` property value. +export const json_compatible_undefined_path = probe_json_save({ a: { b: undefined } }) + +// JSON: `JsonCompatible` names an array element by `[*]`. +declare const probe_json_list: { list: Array<{ run: () => void }> } +export const json_compatible_array_path = probe_json_save(probe_json_list) + +// JSON: `JsonCompatible` rejects a top-level `bigint`. +declare const probe_json_bigint: bigint +export const json_compatible_top_level = probe_json_save(probe_json_bigint) diff --git a/packages/type-plus/error-snapshots/snapshot.txt b/packages/type-plus/error-snapshots/snapshot.txt index 3b0e5b3bb8..8ba75f34f4 100644 --- a/packages/type-plus/error-snapshots/snapshot.txt +++ b/packages/type-plus/error-snapshots/snapshot.txt @@ -1172,3 +1172,36 @@ error TS2345: Argument of type 'true' is not assignable to parameter of type 'fa ## testType_of_constructibleWith_fails error TS2345: Argument of type 'true' is not assignable to parameter of type 'false | Failed<"constructibleWith", DateConstructor, [boolean, boolean, boolean]>'. + +## json_types_undefined_property +error TS2322: Type '{ a: undefined; }' is not assignable to type 'JsonTypes'. + Type '{ a: undefined; }' is not assignable to type 'null'. + +## json_types_interface +error TS2322: Type 'ProbeJsonUser' is not assignable to type 'JsonTypes'. + Type 'ProbeJsonUser' is not assignable to type 'JsonObject'. + Index signature for type 'string' is missing in type 'ProbeJsonUser'. + +## json_compatible_interface +(no error) + +## json_compatible_nested_path +error TS2345: Argument of type 'ProbeJsonPost' is not assignable to parameter of type 'JsonCompatible'. + Type 'ProbeJsonPost' is not assignable to type '_JsonMap & object & { [Symbol.hasInstance]?: undefined; }'. + Type 'ProbeJsonPost' is not assignable to type '_JsonMap'. + The types of 'author.profile.createdAt' are incompatible between these types. + Type 'Date' is not assignable to type '"not JSON at $.author.profile.createdAt"'. + +## json_compatible_undefined_path +error TS2322: Type 'undefined' is not assignable to type '"not JSON at $.a.b"'. + +## json_compatible_array_path +error TS2345: Argument of type '{ list: { run: () => void; }[]; }' is not assignable to parameter of type 'JsonCompatible<{ list: { run: () => void; }[]; }>'. + Type '{ list: { run: () => void; }[]; }' is not assignable to type '_JsonMap<{ list: { run: () => void; }[]; }, "$"> & object & { [Symbol.hasInstance]?: undefined; }'. + Type '{ list: { run: () => void; }[]; }' is not assignable to type '_JsonMap<{ list: { run: () => void; }[]; }, "$">'. + Types of property 'list' are incompatible. + Type '{ run: () => void; }[]' is not assignable to type '"not JSON at $.list[*]"[]'. + Type '{ run: () => void; }' is not assignable to type '"not JSON at $.list[*]"'. + +## json_compatible_top_level +error TS2345: Argument of type 'bigint' is not assignable to parameter of type 'never'. diff --git a/packages/type-plus/readme.md b/packages/type-plus/readme.md index 7753876d59..14d0aa0de2 100644 --- a/packages/type-plus/readme.md +++ b/packages/type-plus/readme.md @@ -902,28 +902,34 @@ Full reference: [JSON](https://cyberuni.github.io/type-plus/api/json/). > `JsonObject` -ðŸ‘― *alias*: JSON object +ðŸ‘― *alias*: JSON object. A key may be absent but never `undefined`. > `JsonArray` -ðŸ‘― *alias*: JSON array +ðŸ‘― *alias*: JSON array, readonly so tuples and `as const` data are accepted. > `JsonTypes` -ðŸ‘― *alias*: all JSON compatible types. +ðŸ‘― *alias*: any value `JSON.parse` can produce. -> `JsonTypes.get(obj, ...props)` +> `JsonCompatible` -ðŸĶī *utilities*: get a cast value in JSON +🧰 *type util*: constrain with `T extends JsonCompatible` to accept any JSON-safe value, including an `interface`. +The compiler error names the path of the part that is not JSON. ```ts -import { JsonTypes } from 'type-plus' +import type { JsonCompatible } from 'type-plus' -const someJson: JsonTypes = { a: { b: ['z', { c: 'miku' }]}} +function save>(value: T) { ... } -JsonTypes.get(someJson, 'a', 'b', 1, 'c') // miku +interface Post { createdAt: Date } +save(post) // Type 'Date' is not assignable to type '"not JSON at $.createdAt"' ``` +> `IsJson`, `IsNotJson` + +🎭 *predicate*: validate if `T` survives a JSON round trip. + ## Type manipulation > `ANotB` diff --git a/packages/type-plus/src/index.ts b/packages/type-plus/src/index.ts index 98a999dafe..d6853de157 100644 --- a/packages/type-plus/src/index.ts +++ b/packages/type-plus/src/index.ts @@ -60,16 +60,10 @@ export type * from './function/is-not-function.js' export type { ChainFn, EndoFn } from './functional/chain-fn.js' export * from './functional/compose.js' export * from './functional/context.js' -export type { - JSONArray, - JSONObject, - JSONPrimitive, - JSONTypes, - JsonArray, - JsonObject, - JsonPrimitive, - JsonTypes, -} from './json.js' +export type * from './is-json.js' +export type * from './is-not-json.js' +export type * from './json.js' +export type * from './json-compatible.js' export type { And, Not, Or, Xor } from './logical/logical.js' export type { Abs } from './math/abs.js' export type { Add, Increment } from './math/add.js' diff --git a/packages/type-plus/src/is-json.spec.ts b/packages/type-plus/src/is-json.spec.ts new file mode 100644 index 0000000000..fa995160cc --- /dev/null +++ b/packages/type-plus/src/is-json.spec.ts @@ -0,0 +1,152 @@ +import { describe, it } from 'vitest' + +import type { $Else, $Fn, $Then, IsJson, JsonTypes } from './index.js' +import { testType } from './testing/index.js' + +interface User { + id: number + tags: string[] + nick?: string +} + +it('returns true for JSON primitives', () => { + testType.true>(true) + testType.true>(true) + testType.true>(true) + testType.true>(true) +}) + +it('returns true for JSON objects and arrays', () => { + testType.true>(true) + testType.true>(true) + testType.true>(true) + testType.true>(true) + testType.true>(true) +}) + +it('returns true for an interface', () => { + testType.true>(true) +}) + +it('returns true for readonly data and optional properties', () => { + testType.true>(true) + testType.true>(true) + testType.true>(true) +}) + +it('returns false for special types', () => { + testType.false>(true) + testType.false>(true) + testType.false>(true) + testType.false>(true) +}) + +it('returns false for what does not survive a JSON round trip', () => { + testType.false>(true) + testType.false>(true) + testType.false>(true) + testType.false void>>(true) + testType.false>(true) + testType.false>(true) + testType.false>(true) + testType.false>(true) + testType.false void> }>>(true) +}) + +it('distributes for union type', () => { + testType.equal, boolean>(true) + testType.equal, true>(true) + testType.equal, false>(true) +}) + +it('can disable union distribution', () => { + testType.equal, boolean>(true) + testType.equal, false>(true) + testType.equal, true>(true) +}) + +describe('filter', () => { + it('returns T if T is JSON', () => { + testType.equal, User>(true) + testType.equal, null>(true) + }) + + it('returns never for special types', () => { + testType.never>(true) + testType.never>(true) + testType.never>(true) + testType.never>(true) + }) + + it('returns never if T is not JSON', () => { + testType.never>(true) + testType.never>(true) + }) + + it('keeps the JSON members of a union', () => { + testType.equal, string>(true) + }) +}) + +describe('$Branch', () => { + it('returns $Then if T is JSON', () => { + testType.equal, $Then>(true) + }) + + it('returns $Else if T is not JSON', () => { + testType.equal, $Else>(true) + testType.equal, $Then | $Else>(true) + }) + + it('returns $Else for special types', () => { + testType.equal, $Else>(true) + testType.equal, $Else>(true) + testType.equal, $Else>(true) + testType.equal, $Else>(true) + }) + + it('can override special branches', () => { + testType.equal, unknown>(true) + testType.equal, unknown>(true) + testType.equal, unknown>(true) + testType.equal, unknown>(true) + }) +}) + +describe('IsJson.$Fn', () => { + it('is IsJson as a type function', () => { + testType.equal<$Fn.Apply, true>(true) + testType.equal<$Fn.Apply, false>(true) + }) +}) + +it('resolves `IsJson.$Default` the same as no options', () => { + testType.equal, IsJson>(true) + testType.equal, IsJson>(true) + testType.equal, IsJson>(true) + testType.equal, IsJson>(true) + testType.equal, IsJson>(true) + testType.equal, IsJson>(true) + testType.equal, IsJson>(true) +}) + +describe('without options', () => { + // Without options the type takes a shortcut past the options machinery. + // `{ selection: 'predicate' }` is the default spelled out, which takes the full path. + it('equals the full path with default options', () => { + testType.equal, IsJson>(true) + testType.equal, IsJson>(true) + testType.equal, IsJson>(true) + testType.equal, IsJson>(true) + testType.equal, IsJson>(true) + testType.equal, IsJson>(true) + testType.equal, IsJson<{} | null | undefined, { selection: 'predicate' }>>(true) + testType.equal, IsJson>(true) + testType.equal, IsJson<1n, { selection: 'predicate' }>>(true) + testType.equal, IsJson>(true) + testType.equal, IsJson>(true) + testType.equal void>, IsJson<() => void, { selection: 'predicate' }>>(true) + testType.equal, IsJson>(true) + testType.equal, IsJson>(true) + }) +}) diff --git a/packages/type-plus/src/is-json.ts b/packages/type-plus/src/is-json.ts new file mode 100644 index 0000000000..5d722143d4 --- /dev/null +++ b/packages/type-plus/src/is-json.ts @@ -0,0 +1,136 @@ +import type { $InputOptions } from './$type/branch/$input-options.js' +import type { $ResolveBranch } from './$type/branch/$resolve-branch.js' +import type { $Else, $Selection, $Then } from './$type/branch/$selection.js' +import type { $Distributive } from './$type/distributive/$distributive.js' +import type { $Fn as $FnBase } from './$type/fn/$fn.js' +import type { $Any } from './$type/special/$any.js' +import type { $Never } from './$type/special/$never.js' +import type { $Special } from './$type/special/$special.js' +import type { $Unknown } from './$type/special/$unknown.js' +import type { $Void } from './$type/special/$void.js' +import type { $MergeOptions } from './$type/utils/$merge-options.js' +import type { $StrictOptions } from './$type/utils/$strict-options.js' +import type { JsonCompatible } from './json-compatible.js' + +/** + * 🎭 *predicate* + * + * Validate if `T` survives a JSON round trip: `T` is assignable to + * `JsonCompatible`. + * + * Unlike `T extends JsonTypes`, it accepts an `interface`, readonly arrays and + * tuples, and optional properties. It rejects functions, objects with methods + * (such as `Date`), `bigint`, `symbol` and `undefined` values. + * + * To find out *where* a type fails, constrain with `JsonCompatible` instead: + * its compiler error names the path of the offending property. + * + * @example + * ```ts + * interface User { id: number; tags: string[]; nick?: string } + * + * type R = IsJson // true + * type R = IsJson<{ a: readonly [1, 2] }> // true + * type R = IsJson // true + * + * type R = IsJson<{ a: Date }> // false + * type R = IsJson<{ a: undefined }> // false + * type R = IsJson // false + * type R = IsJson // false + * type R = IsJson // false + * + * type R = IsJson // boolean + * ``` + * + * ðŸ”Ē *customize* + * + * Filter to ensure `T` survives a JSON round trip, otherwise returns `never`. + * + * @example + * ```ts + * type R = IsJson // User + * type R = IsJson // string + * type R = IsJson // never + * ``` + * + * ðŸ”Ē *customize* + * + * Disable distribution of union types. + * + * @example + * ```ts + * type R = IsJson // boolean + * type R = IsJson // false + * ``` + * + * ðŸ”Ē *customize* + * + * Use unique branch identifiers to allow precise processing of the result. + * + * @example + * ```ts + * type R = IsJson // $Then + * type R = IsJson // $Else + * ``` + * + * Without options, it answers through `$Special.Values`, skipping the options machinery. + * + * @since 🏷ïļ 8.0.0 + */ +export type IsJson = {}> = [keyof $O] extends [never] + ? $Special.Values< + T, + { $any: false; $unknown: false; $never: false; $void: false; $else: T extends JsonCompatible ? true : false } + > + : $Special< + T, + $MergeOptions< + $O, + { + $then: $ResolveBranch<$O, [$Else]> + $else: IsJson.$ + } + > + > + +export namespace IsJson { + export interface $Options + extends $Selection.Options, + $Distributive.Options, + $InputOptions<$Any | $Unknown | $Never | $Void> {} + export type $Default = $Selection.Predicate & $Distributive.Default + export type $Branch<$O extends $Options = {}> = $Selection.Branch<$O> + + /** + * 🧰 *type function* + * + * `IsJson` as a type function, with its options `$O` applied. + * + * @example + * ```ts + * type R = $Fn.Apply // true + * type R = $Fn.Apply // false + * ``` + */ + export interface $Fn<$O extends $StrictOptions<$O, $Options> = {}> extends $FnBase { + readonly out: IsJson + } + + /** + * 🧰 *type util* + * + * Validate if `T` survives a JSON round trip. + * + * This is a type util for building custom types. + * It does not check against special types. + */ + export type $ = $Distributive.Parse< + $O, + { + $then: T extends JsonCompatible ? $ResolveBranch<$O, [$Then], T> : $ResolveBranch<$O, [$Else]> + $else: [T] extends [JsonCompatible] ? $ResolveBranch<$O, [$Then], T> : $ResolveBranch<$O, [$Else]> + } + > +} + +type $UtilOptions = $Selection.Options & $Distributive.Options diff --git a/packages/type-plus/src/is-not-json.spec.ts b/packages/type-plus/src/is-not-json.spec.ts new file mode 100644 index 0000000000..5957f15f59 --- /dev/null +++ b/packages/type-plus/src/is-not-json.spec.ts @@ -0,0 +1,94 @@ +import { describe, it } from 'vitest' + +import type { $Else, $Fn, $Then, IsNotJson } from './index.js' +import { testType } from './testing/index.js' + +interface User { + id: number + tags: string[] + nick?: string +} + +it('returns false for JSON values', () => { + testType.false>(true) + testType.false>(true) + testType.false>(true) + testType.false>(true) + testType.false>(true) + testType.false>(true) + testType.false>(true) +}) + +it('returns true for special types', () => { + testType.true>(true) + testType.true>(true) + testType.true>(true) + testType.true>(true) +}) + +it('returns true for what does not survive a JSON round trip', () => { + testType.true>(true) + testType.true>(true) + testType.true void>>(true) + testType.true>(true) + testType.true>(true) + testType.true>(true) +}) + +it('distributes for union type', () => { + testType.equal, boolean>(true) + testType.equal, true>(true) +}) + +describe('filter', () => { + it('keeps the members of T that are not JSON', () => { + testType.equal, Date>(true) + testType.never>(true) + }) + + it('returns T for special types, except never', () => { + testType.equal, unknown>(true) + testType.never>(true) + }) +}) + +describe('$Branch', () => { + it('returns $Then if T is not JSON', () => { + testType.equal, $Then>(true) + }) + + it('returns $Else if T is JSON', () => { + testType.equal, $Else>(true) + }) +}) + +describe('IsNotJson.$Fn', () => { + it('is IsNotJson as a type function', () => { + testType.equal<$Fn.Apply, true>(true) + testType.equal<$Fn.Apply, false>(true) + }) +}) + +it('resolves `IsNotJson.$Default` the same as no options', () => { + testType.equal, IsNotJson>(true) + testType.equal, IsNotJson>(true) + testType.equal, IsNotJson>(true) + testType.equal, IsNotJson>(true) + testType.equal, IsNotJson>(true) + testType.equal, IsNotJson>(true) + testType.equal, IsNotJson>(true) +}) + +describe('without options', () => { + it('equals the full path with default options', () => { + testType.equal, IsNotJson>(true) + testType.equal, IsNotJson>(true) + testType.equal, IsNotJson>(true) + testType.equal, IsNotJson>(true) + testType.equal, IsNotJson>(true) + testType.equal, IsNotJson>(true) + testType.equal, IsNotJson>(true) + testType.equal, IsNotJson>(true) + testType.equal, IsNotJson>(true) + }) +}) diff --git a/packages/type-plus/src/is-not-json.ts b/packages/type-plus/src/is-not-json.ts new file mode 100644 index 0000000000..f55f2557fb --- /dev/null +++ b/packages/type-plus/src/is-not-json.ts @@ -0,0 +1,132 @@ +import type { $InputOptions } from './$type/branch/$input-options.js' +import type { $ResolveBranch } from './$type/branch/$resolve-branch.js' +import type { $Else, $Selection, $Then } from './$type/branch/$selection.js' +import type { $Distributive } from './$type/distributive/$distributive.js' +import type { $Fn as $FnBase } from './$type/fn/$fn.js' +import type { $Any } from './$type/special/$any.js' +import type { $Never } from './$type/special/$never.js' +import type { $Special } from './$type/special/$special.js' +import type { $Unknown } from './$type/special/$unknown.js' +import type { $Void } from './$type/special/$void.js' +import type { $MergeOptions } from './$type/utils/$merge-options.js' +import type { $StrictOptions } from './$type/utils/$strict-options.js' +import type { JsonCompatible } from './json-compatible.js' + +/** + * 🎭 *predicate* + * + * Validate if `T` does not survive a JSON round trip: `T` is not assignable to + * `JsonCompatible`. + * + * The negation of `IsJson`. To find out *where* a type fails, constrain with + * `JsonCompatible`: its compiler error names the path of the offending property. + * + * @example + * ```ts + * interface User { id: number; tags: string[]; nick?: string } + * + * type R = IsNotJson // false + * type R = IsNotJson // false + * + * type R = IsNotJson<{ a: Date }> // true + * type R = IsNotJson<{ a: undefined }> // true + * type R = IsNotJson // true + * type R = IsNotJson // true + * type R = IsNotJson // true + * + * type R = IsNotJson // boolean + * ``` + * + * ðŸ”Ē *customize* + * + * Filter to ensure `T` does not survive a JSON round trip, otherwise returns `never`. + * + * @example + * ```ts + * type R = IsNotJson // Date + * type R = IsNotJson // never + * ``` + * + * ðŸ”Ē *customize* + * + * Disable distribution of union types. + * + * @example + * ```ts + * type R = IsNotJson // boolean + * type R = IsNotJson // true + * ``` + * + * ðŸ”Ē *customize* + * + * Use unique branch identifiers to allow precise processing of the result. + * + * @example + * ```ts + * type R = IsNotJson // $Then + * type R = IsNotJson // $Else + * ``` + * + * Without options, it answers through `$Special.Values`, skipping the options machinery. + * + * @since 🏷ïļ 8.0.0 + */ +export type IsNotJson = {}> = [keyof $O] extends [never] + ? $Special.Values< + T, + { $any: true; $unknown: true; $never: true; $void: true; $else: T extends JsonCompatible ? false : true } + > + : $Special< + T, + $MergeOptions< + $O, + { + $then: $ResolveBranch<$O, [$Then], T> + $else: IsNotJson.$ + } + > + > + +export namespace IsNotJson { + export interface $Options + extends $Selection.Options, + $Distributive.Options, + $InputOptions<$Any | $Unknown | $Never | $Void> {} + export type $Default = $Selection.Predicate & $Distributive.Default + export type $Branch<$O extends $Options = {}> = $Selection.Branch<$O> + + /** + * 🧰 *type function* + * + * `IsNotJson` as a type function, with its options `$O` applied. + * + * Prefer it over `$Fn.Not`: it costs less. + * + * @example + * ```ts + * type R = $Fn.Apply // true + * type R = $Fn.Apply // false + * ``` + */ + export interface $Fn<$O extends $StrictOptions<$O, $Options> = {}> extends $FnBase { + readonly out: IsNotJson + } + + /** + * 🧰 *type util* + * + * Validate if `T` does not survive a JSON round trip. + * + * This is a type util for building custom types. + * It does not check against special types. + */ + export type $ = $Distributive.Parse< + $O, + { + $then: T extends JsonCompatible ? $ResolveBranch<$O, [$Else]> : $ResolveBranch<$O, [$Then], T> + $else: [T] extends [JsonCompatible] ? $ResolveBranch<$O, [$Else]> : $ResolveBranch<$O, [$Then], T> + } + > +} + +type $UtilOptions = $Selection.Options & $Distributive.Options diff --git a/packages/type-plus/src/json-compatible.spec.ts b/packages/type-plus/src/json-compatible.spec.ts new file mode 100644 index 0000000000..98332b18e1 --- /dev/null +++ b/packages/type-plus/src/json-compatible.spec.ts @@ -0,0 +1,110 @@ +import { describe, it } from 'vitest' + +import type { JsonCompatible, JsonTypes } from './index.js' +import { testType } from './testing/index.js' + +function save>(value: T) { + return value +} + +describe('as a self-referencing constraint', () => { + it('accepts an interface', () => { + interface User { + id: number + tags: string[] + nick?: string + } + const user: User = { id: 1, tags: [] } + save(user) + }) + + it('accepts a recursive interface', () => { + interface Tree { + value: number + children: Tree[] + } + const tree: Tree = { value: 1, children: [] } + save(tree) + }) + + it('accepts readonly and `as const` data', () => { + save({ a: [1, 2] } as const) + save([1, 'x'] as const) + const a: readonly string[] = [] + save(a) + }) + + it('accepts an optional property', () => { + const o: { a?: number } = {} + save(o) + }) + + it('accepts JSON primitives', () => { + save(1) + save('x') + save(true) + save(null) + }) + + it('accepts any JsonTypes', () => { + const v: JsonTypes = JSON.parse('1') + save(v) + }) + + it('accepts an object with a key named like a function member', () => { + save({ call: 'x', apply: 1, bind: null }) + }) + + it('rejects a property holding undefined, naming its path', () => { + // @ts-expect-error Type 'undefined' is not assignable to type '"not JSON at $.a"' + save({ a: undefined }) + interface User { + nick: string | undefined + } + const user: User = { nick: undefined } + // @ts-expect-error `nick` may hold undefined + save(user) + }) + + it('rejects a nested Date, naming its path', () => { + interface Post { + profile: { createdAt: Date } + } + const post: Post = { profile: { createdAt: new Date() } } + // @ts-expect-error Type 'Date' is not assignable to type '"not JSON at $.profile.createdAt"' + save(post) + }) + + it('rejects a function inside an array', () => { + // @ts-expect-error Type '() => number' is not assignable to type '"not JSON at $[1]"' + save([1, () => 1] as const) + }) + + it('rejects non-JSON values at the top level', () => { + // @ts-expect-error + save(undefined) + // @ts-expect-error + save(1n) + // @ts-expect-error + save(Symbol()) + // @ts-expect-error + save(() => 1) + // @ts-expect-error + save(new Date()) + // @ts-expect-error + save(new Map()) + }) +}) + +describe('as a type', () => { + it('admits T when T is JSON', () => { + testType.equal<{ a: 1 } extends JsonCompatible<{ a: 1 }> ? true : false, true>(true) + }) + + it('replaces each part that is not JSON with a message naming its path', () => { + type R = JsonCompatible<{ a: { b: Date } }> + testType.equal(true) + testType.equal void> }>['a'], Array<'not JSON at $.a[*]'>>(true) + testType.equal void] }>['a'], [1, 'not JSON at $.a[1]']>(true) + }) +}) diff --git a/packages/type-plus/src/json-compatible.ts b/packages/type-plus/src/json-compatible.ts new file mode 100644 index 0000000000..ff41a8dac0 --- /dev/null +++ b/packages/type-plus/src/json-compatible.ts @@ -0,0 +1,87 @@ +import type { JsonPrimitive, JsonTypes } from './json.js' + +/** + * 🧰 *type util* + * + * `T` with every part that does not survive a JSON round trip replaced by a + * message naming where it is. Use it as a self-referencing constraint, + * `T extends JsonCompatible`, to accept a JSON-safe value of any declared + * type, including an `interface`. + * + * `JsonTypes` cannot do this: TypeScript gives an interface no implicit index + * signature, so `interface User { id: number }` is never assignable to it. + * `JsonCompatible` is built from `T`'s own keys instead, so it has no index + * signature to satisfy. + * + * It accepts what `JsonTypes` accepts, plus interfaces, readonly arrays and + * tuples, and optional properties. It rejects functions, objects with methods + * (such as `Date` and `Map`), `bigint`, `symbol`, and `undefined` anywhere but + * the implicit value of an optional property. + * + * A rejected part is replaced by the string literal `` `not JSON at ${path}` ``, + * where `path` is a JSONPath from the root `$`, so the compiler error names + * where the problem is: `Type 'Date' is not assignable to type + * '"not JSON at $.profile.createdAt"'`. Array elements show as `[*]` and tuple + * elements by index. + * + * @example + * ```ts + * function save>(value: T) { ... } + * + * interface User { id: number; tags: string[]; nick?: string } + * declare const user: User + * save(user) // ok + * save({ a: [1, 2] } as const) // ok + * + * interface Post { createdAt: Date } + * declare const post: Post + * save(post) // Type 'Date' is not assignable to type '"not JSON at $.createdAt"' + * save({ a: undefined }) // Type 'undefined' is not assignable to type '"not JSON at $.a"' + * ``` + * + * @example + * ```ts + * type R = { a: 1 } extends JsonCompatible<{ a: 1 }> ? true : false // true + * type R = JsonCompatible<{ a: { b: Date } }>['a'] // { b: 'not JSON at $.a.b' } + * type R = JsonCompatible<{ a: Array<() => void> }>['a'] // Array<'not JSON at $.a[*]'> + * ``` + * + * @since 🏷ïļ 8.0.0 + */ +export type JsonCompatible = _JsonMap & (JsonPrimitive | (object & { [Symbol.hasInstance]?: never })) + +type _AnyFunction = (...args: any[]) => any + +/** + * `T`'s keys, each holding its value checked at the path below `P`. + * + * A homomorphic mapped type keeps `T`'s optional and readonly modifiers, keeps + * an array an array, and maps a primitive to itself. + */ +type _JsonMap = { + [K in keyof T]: _JsonCheck}`> +} + +type _JsonSegment = InArray extends true + ? number extends K + ? '[*]' + : `[${K & (string | number)}]` + : `.${K & (string | number)}` + +/** + * `T` itself when it is already JSON, which also stops the recursion on + * `JsonTypes`; otherwise the check of each part of `T`. + */ +type _JsonCheck = T extends JsonTypes + ? T + : T extends _AnyFunction + ? _JsonFail

+ : T extends readonly unknown[] + ? _JsonMap + : T extends object + ? true extends { [K in keyof T]-?: T[K] extends _AnyFunction ? true : false }[keyof T] + ? _JsonFail

+ : _JsonMap + : _JsonFail

+ +type _JsonFail

= `not JSON at ${P}` diff --git a/packages/type-plus/src/json.spec.ts b/packages/type-plus/src/json.spec.ts index ebe43b01c3..77cc0124fb 100644 --- a/packages/type-plus/src/json.spec.ts +++ b/packages/type-plus/src/json.spec.ts @@ -1,94 +1,110 @@ -import { describe, expect, test } from 'vitest' +import { describe, it } from 'vitest' -import { - type JSONArray, - type JSONObject, - type JSONPrimitive, +import type { + JSONArray, + JSONObject, + JSONPrimitive, JSONTypes, - type JsonArray, - type JsonObject, - type JsonPrimitive, + JsonArray, + JsonObject, + JsonPrimitive, JsonTypes, -} from './json.js' +} from './index.js' import { testType } from './testing/index.js' -test('JsonTypes is the union of the three JSON shapes', () => { - testType.equal(true) -}) - -test('admits what survives a JSON round trip, and nothing else', () => { - testType.equal<{ a: 1 } extends JsonTypes ? true : false, true>(true) - testType.equal(true) -}) +describe('JsonTypes', () => { + it('is the union of the three JSON shapes', () => { + testType.equal(true) + }) -test('JsonPrimitive has no undefined, because JSON has no such value', () => { - testType.equal(true) - testType.equal(true) - testType.equal(true) -}) + it('admits what survives a JSON round trip', () => { + testType.equal<{ a: 1 } extends JsonTypes ? true : false, true>(true) + testType.equal<{ readonly a: readonly [1, 2] } extends JsonTypes ? true : false, true>(true) + ;({}) satisfies JsonTypes + ;[] satisfies JsonTypes + ;['a'] satisfies JsonTypes + ;({ a: [1, { b: null }] }) satisfies JsonTypes + }) -test('every JsonObject property is optional, so reading one may be undefined', () => { - testType.equal(true) - testType.equal<{ a: 1; b: 'x' } extends JsonObject ? true : false, true>(true) - testType.equal<{ a: Date } extends JsonObject ? true : false, false>(true) -}) + it('admits readonly and `as const` data', () => { + ;({ a: [1, 2] }) as const satisfies JsonTypes + ;[1, 'x'] as const satisfies JsonTypes + }) -test('a JsonArray holds any mix of JsonTypes', () => { - testType.equal(true) - testType.equal<[1, 'x', null] extends JsonArray ? true : false, true>(true) - testType.equal<[() => void] extends JsonArray ? true : false, false>(true) -}) + it('rejects what does not survive a JSON round trip', () => { + testType.equal(true) + testType.equal(true) + testType.equal(true) + testType.equal<(() => void) extends JsonTypes ? true : false, false>(true) + }) -test('empty object', () => { - ;({}) satisfies JsonTypes -}) + it('rejects a property holding undefined', () => { + testType.equal<{ a: undefined } extends JsonTypes ? true : false, false>(true) + // @ts-expect-error `undefined` is not a JSON value + const r: JsonTypes = { a: undefined } + void r + }) -test('empty array', () => { - ;[] satisfies JsonTypes -}) + it('admits an optional property', () => { + testType.equal<{ a?: number } extends JsonTypes ? true : false, true>(true) + const o: { a?: number } = {} + o satisfies JsonTypes + }) -test('string array', () => { - ;['a'] satisfies JsonTypes + it('rejects an interface, which has no implicit index signature', () => { + interface User { + id: number + } + testType.equal(true) + }) }) -test('JsonObject', () => { - ;({}) satisfies JsonObject +describe('JsonPrimitive', () => { + it('has no undefined, because JSON has no such value', () => { + testType.equal(true) + testType.equal(true) + testType.equal(true) + }) }) -describe('JsonTypes.get', () => { - test('cast to T | undefined without props', () => { - const a = JsonTypes.get('abc') - testType.equal(true) - expect(a).toBe('abc') +describe('JsonObject', () => { + it('holds JsonTypes under string keys', () => { + testType.equal(true) + testType.equal<{ a: 1; b: 'x' } extends JsonObject ? true : false, true>(true) + testType.equal<{ a: Date } extends JsonObject ? true : false, false>(true) + ;({}) satisfies JsonObject }) - test('get object props', () => { - const a = JsonTypes.get({ a: { b: 'abc' } }, 'a', 'b') - testType.equal(true) - expect(a).toBe('abc') + + it('admits an absent key but not an undefined one', () => { + testType.equal<{ a?: number } extends JsonObject ? true : false, true>(true) + testType.equal<{ a: undefined } extends JsonObject ? true : false, false>(true) + testType.equal<{ a: number | undefined } extends JsonObject ? true : false, false>(true) }) - test('get array entry', () => { - const a = JsonTypes.get(['abc'], 0) - testType.equal(true) - expect(a).toBe('abc') + it('reads a key as possibly absent under noUncheckedIndexedAccess', () => { + const o: JsonObject = JSON.parse('{}') + const r = o['a'] + testType.equal(true) }) - test('undefined when any step of the path is missing', () => { - const doc = { a: { b: [1, 2] } } - expect(JsonTypes.get(doc, 'a', 'nope')).toBe(undefined) - expect(JsonTypes.get(doc, 'a', 'b', 'c', 'd')).toBe(undefined) +}) + +describe('JsonArray', () => { + it('holds any mix of JsonTypes', () => { + testType.equal(true) + testType.equal<[1, 'x', null] extends JsonArray ? true : false, true>(true) + testType.equal<[() => void] extends JsonArray ? true : false, false>(true) }) - test('nested', () => { - const a = JsonTypes.get({ a: { b: [{ c: 'abc' }] } }, 'a', 'b', 0, 'c') - testType.equal(true) - expect(a).toBe('abc') + it('admits readonly arrays and tuples', () => { + testType.equal(true) + testType.equal(true) + testType.equal(true) }) }) -test('the deprecated JSON* names are the same types', () => { +it('the deprecated JSON* names are the same types', () => { testType.equal(true) testType.equal(true) testType.equal(true) testType.equal(true) - expect(JSONTypes).toBe(JsonTypes) }) diff --git a/packages/type-plus/src/json.ts b/packages/type-plus/src/json.ts index fc0c58a8ad..dadb84e369 100644 --- a/packages/type-plus/src/json.ts +++ b/packages/type-plus/src/json.ts @@ -3,17 +3,24 @@ * * Any value `JSON.parse` can produce: a primitive, an object, or an array. * - * Use it as the constraint on data that has crossed a JSON boundary. It admits + * Use it as the type of data that has crossed a JSON boundary. It admits * exactly what survives the round trip, so a `Date`, a `Map`, a function or - * `undefined` are rejected at the point they are put in rather than silently - * lost on the way out. + * an `undefined` value are rejected at the point they are put in rather than + * silently lost on the way out. Readonly arrays and tuples, `as const` data + * and optional properties are accepted. + * + * An `interface` is not assignable to it, because TypeScript gives interfaces + * no implicit index signature. To accept an interface, constrain with + * `JsonCompatible` or test with `IsJson`. * * @example * ```ts * type R = JsonTypes // JsonPrimitive | JsonObject | JsonArray * * type R = { a: 1 } extends JsonTypes ? true : false // true + * type R = { readonly a: readonly [1, 2] } extends JsonTypes ? true : false // true * type R = Date extends JsonTypes ? true : false // false + * type R = { a: undefined } extends JsonTypes ? true : false // false * ``` */ export type JsonTypes = JsonPrimitive | JsonObject | JsonArray @@ -41,70 +48,49 @@ export type JsonPrimitive = boolean | number | string | null * * A JSON object: string keys holding any `JsonTypes` value. * - * Every property is optional, so reading one yields `JsonTypes | undefined`. - * That is the honest type for parsed data, where no key is guaranteed to be - * present, and it is why `JsonTypes.get` returns `T | undefined`. + * A property may be absent but never `undefined`: `{ a?: number }` is + * accepted, because `JSON.stringify` drops a missing key the same way it is + * missing after `JSON.parse`, while `{ a: undefined }` is rejected. + * + * Reading a key from it is an index-signature read. Under + * `noUncheckedIndexedAccess` that read is `JsonTypes | undefined`, which is the + * honest type for parsed data, where no key is guaranteed to be present. + * TypeScript has no index signature that rejects `undefined` on the way in and + * adds it on the way out, so without that option the read is `JsonTypes`. * * @example * ```ts - * type R = JsonObject['a'] // JsonTypes | undefined + * type R = JsonObject[string] // JsonTypes + * + * declare const o: JsonObject + * const r = o.a // JsonTypes | undefined, under noUncheckedIndexedAccess * * type R = { a: 1; b: 'x' } extends JsonObject ? true : false // true + * type R = { a?: number } extends JsonObject ? true : false // true + * type R = { a: undefined } extends JsonObject ? true : false // false * type R = { a: Date } extends JsonObject ? true : false // false * ``` */ -export type JsonObject = { [key in string]?: JsonTypes } +export type JsonObject = { [key: string]: JsonTypes } /** * 🧰 *type util* * * A JSON array: any number of `JsonTypes` values, not necessarily of one type. * + * It is readonly, so mutable arrays, readonly arrays, tuples and `as const` + * tuples are all accepted. + * * @example * ```ts * type R = JsonArray[number] // JsonTypes * * type R = [1, 'x', null] extends JsonArray ? true : false // true + * type R = readonly [1, 'x'] extends JsonArray ? true : false // true * type R = [() => void] extends JsonArray ? true : false // false * ``` */ -export type JsonArray = Array - -/** - * The value half of `JsonTypes`, holding `get`. - * - * Note: `src/index.ts` re-exports `JsonTypes` with `export type`, so only the - * type reaches the package entry. This value is reachable inside the package - * and from the declaration file, not from `import { JsonTypes } from - * 'type-plus'`. - */ -export const JsonTypes = { get } - -/** - * Reads the value at a property path out of a JSON value, or `undefined` when - * any step of the path is missing. - * - * The path is walked one property at a time and a non-object hit anywhere along - * it stops the walk, so a wrong path returns `undefined` instead of throwing. - * `T` is the caller's claim about what is there, not a checked fact: nothing - * validates the value against it. With no props, `obj` is returned as `T`. - * - * @example - * ```ts - * const doc = { a: { b: [1, 2] } } - * - * const r = JsonTypes.get(doc, 'a', 'b', 1) // 2 - * const r = JsonTypes.get(doc, 'a', 'nope') // undefined - * const r = JsonTypes.get(doc, 'a', 'b', 'c', 'd') // undefined - * ``` - */ -function get(obj: JsonTypes, ...props: Array): T | undefined { - if (props.length === 0) return obj as T - if (typeof obj !== 'object' || obj === null) return undefined - const p = props.shift()! - // @ts-expect-error - return get(obj[p], ...props) as T -} +export type JsonArray = readonly JsonTypes[] /** * The pre-8.0 name of `JsonTypes`. @@ -113,13 +99,6 @@ function get(obj: JsonTypes, ...props: Array Date: Sat, 10 Oct 2026 18:51:39 -0700 Subject: [PATCH 2/2] docs(website): document JsonCompatible, IsJson and the new JSON behavior Co-Authored-By: Claude Opus 5.5 --- apps/website/public/llms.txt | 6 +- apps/website/src/content/docs/api/json.md | 106 +++++++++++++++--- .../content/docs/guides/migrating-to-v8.mdx | 28 +++++ packages/type-plus/llms.txt | 6 +- .../type-plus/scripts/generate-llms-txt.mjs | 3 + 5 files changed, 127 insertions(+), 22 deletions(-) diff --git a/apps/website/public/llms.txt b/apps/website/public/llms.txt index cedc3034b3..cb199c078f 100644 --- a/apps/website/public/llms.txt +++ b/apps/website/public/llms.txt @@ -1,6 +1,6 @@ # type-plus -> 270 utility types and functions for TypeScript, most of which resolve at the type level and emit no runtime code. This file is generated from the exported surface of `type-plus` and `type-plus/testing`; edit `packages/type-plus/scripts/generate-llms-txt.mjs`, not this file. +> 273 utility types and functions for TypeScript, most of which resolve at the type level and emit no runtime code. This file is generated from the exported surface of `type-plus` and `type-plus/testing`; edit `packages/type-plus/scripts/generate-llms-txt.mjs`, not this file. Most of `type-plus` is types, not values. Importing a type costs nothing at runtime -- it is erased at compile time, and the shipped declarations are several times the size of the shipped JavaScript. Import types with `import type` and the rest with a plain `import`. @@ -13,7 +13,7 @@ Four conventions govern almost every type in the library. Knowing them is worth - `{ distributive: false }` turns off distribution over unions. By default a check applied to `A | B` is applied to each member and the results are unioned; with distribution off the union is judged as a whole. - Every branch of a check is overridable through the same options object: `IsNever`, and likewise `$unknown`, `$then` and `$else`. Passing the `Is*.$Branch` selector instead -- `IsNever` -- yields the `$Then` / `$Else` markers, which is what makes a check composable inside another type. -The authoritative, per-symbol detail is the TSDoc in the shipped `.d.ts` files, under `node_modules/type-plus/esm/`. Every export declared in this package carries TSDoc (270 of the 270). A documented type states what it resolves to in `@example` blocks. Read the declaration before guessing at a signature. +The authoritative, per-symbol detail is the TSDoc in the shipped `.d.ts` files, under `node_modules/type-plus/esm/`. Every export declared in this package carries TSDoc (273 of the 273). A documented type states what it resolves to in `@example` blocks. Read the declaration before guessing at a signature. ## Guides @@ -34,7 +34,7 @@ The authoritative, per-symbol detail is the TSDoc in the shipped `.d.ts` files, - [Boolean and Logical](https://cyberuni.github.io/type-plus/api/boolean/): 11 exports from `src/binary/`, `src/boolean/`, `src/logical/` -- And, Bit, IsBoolean, IsFalse, IsNotBoolean, IsNotFalse, IsNotTrue, IsTrue, Not, Or, Xor. - [Equality](https://cyberuni.github.io/type-plus/api/equality/): 2 exports from `src/equal/` -- IsEqual, IsNotEqual. - [Function and Functional](https://cyberuni.github.io/type-plus/api/function/): 13 exports from `src/class/`, `src/function/`, `src/functional/` -- AnyConstructor, AnyFunction, ChainFn, ContextBaseShape, ContextBuilder, ContextExtender, EndoFn, ExtractFunction, IsFunction, IsNotFunction, compose, context, extractFunction. -- [JSON](https://cyberuni.github.io/type-plus/api/json/): 8 exports from `src/json.ts` -- JSONArray, JSONObject, JSONPrimitive, JSONTypes, JsonArray, JsonObject, JsonPrimitive, JsonTypes. +- [JSON](https://cyberuni.github.io/type-plus/api/json/): 11 exports from `src/is-json.ts`, `src/is-not-json.ts`, `src/json-compatible.ts`, `src/json.ts` -- IsJson, IsNotJson, JSONArray, JSONObject, JSONPrimitive, JSONTypes, JsonArray, JsonCompatible, JsonObject, JsonPrimitive, JsonTypes. - [Math](https://cyberuni.github.io/type-plus/api/math/): 16 exports from `src/math/` -- Abs, Add, Decrement, Divide, GreaterThan, GreaterThanOrEqual, Increment, LessThan, LessThanOrEqual, MathPlus, Max, Min, Multiply, Quotient, Remainder, Subtract. - [Node.js](https://cyberuni.github.io/type-plus/api/nodejs/): 3 exports from `src/nodejs/` -- SystemErrorCodes, SystemErrors, isSystemError. - [Nominal Types](https://cyberuni.github.io/type-plus/api/nominal/): 8 exports from `src/nominal/` -- Brand, Branded, Flavor, Flavored, FlavoredUnit, brand, flavor, nominalMatch. diff --git a/apps/website/src/content/docs/api/json.md b/apps/website/src/content/docs/api/json.md index 96160bf266..5279b58d12 100644 --- a/apps/website/src/content/docs/api/json.md +++ b/apps/website/src/content/docs/api/json.md @@ -1,6 +1,6 @@ --- title: JSON -description: The types of the values that survive a JSON round trip - JsonTypes and its primitive, object and array parts. +description: The types of the values that survive a JSON round trip - JsonTypes and its parts, JsonCompatible for interfaces, and the IsJson predicates. sidebar: order: 19 --- @@ -16,28 +16,96 @@ For the other named sets of types, such as `PrimitiveTypes`, see [Type Sets](/ty ```ts type JsonTypes = JsonPrimitive | JsonObject | JsonArray type JsonPrimitive = boolean | number | string | null -type JsonObject = { [key in string]?: JsonTypes } -type JsonArray = Array +type JsonObject = { [key: string]: JsonTypes } +type JsonArray = readonly JsonTypes[] ``` -🧰 *type util* — the values that survive a JSON round trip. +🧰 *type util* — any value `JSON.parse` can produce. ```ts import type { JsonTypes } from 'type-plus' type R = { a: 1 } extends JsonTypes ? true : false // true +type R = { readonly a: readonly [1, 2] } extends JsonTypes ? true : false // true +type R = { a?: number } extends JsonTypes ? true : false // true + type R = Date extends JsonTypes ? true : false // false -type R = undefined extends JsonPrimitive ? true : false // false +type R = { a: undefined } extends JsonTypes ? true : false // false +``` + +`JsonTypes` admits exactly what survives the round trip, so a `Date`, a `Map`, a function or an +`undefined` value is rejected where it is put in rather than silently lost on the way out. + +- `undefined` is absent from `JsonPrimitive`, because JSON has no such value. +- A `JsonObject` key may be absent but never `undefined`. `{ a?: number }` is accepted, because + `JSON.stringify` drops a missing key and it comes back missing. `{ a: undefined }` is rejected. +- `JsonArray` is readonly, so readonly arrays, tuples and `as const` data are accepted. + +Reading a key from a `JsonObject` is an index-signature read. Under `noUncheckedIndexedAccess` it is +`JsonTypes | undefined`, which is the honest type for parsed data, where no key is guaranteed to be there. +Without that option the read is `JsonTypes`. TypeScript has no index signature that rejects `undefined` on +the way in and adds it on the way out. + +An `interface` is not assignable to `JsonTypes`, because TypeScript gives interfaces no implicit index +signature. Use `JsonCompatible` or `IsJson` for those. + +## `JsonCompatible` + +```ts +type JsonCompatible +``` + +🧰 *type util* — `T` with every part that is not JSON replaced by a message naming where it is. + +Use it as a self-referencing constraint to accept a JSON-safe value of any declared type, including an +`interface`: + +```ts +import type { JsonCompatible } from 'type-plus' + +function save>(value: T) { ... } + +interface User { id: number; tags: string[]; nick?: string } +save(user) // ok +save({ a: [1, 2] } as const) // ok + +interface Post { author: { profile: { createdAt: Date } } } +save(post) +// Type 'Date' is not assignable to type '"not JSON at $.author.profile.createdAt"' +save({ a: undefined }) +// Type 'undefined' is not assignable to type '"not JSON at $.a"' +``` + +The path is a JSONPath from the root `$`. An array element shows as `[*]` and a tuple element by its index. + +It rejects functions, objects with methods (such as `Date` and `Map`), `bigint`, `symbol`, and `undefined` +anywhere but the implicit value of an optional property. + +## `IsJson` and `IsNotJson` + +```ts +type IsJson +type IsNotJson ``` -Use `JsonTypes` as the constraint on data that has crossed a JSON boundary. It admits exactly what comes -back, so a `Date`, a `Map`, a function or `undefined` is rejected where it is put in rather than silently -lost on the way out. +🎭 *predicate* — validate if `T` survives a JSON round trip, `T` being assignable to `JsonCompatible`. + +```ts +import type { IsJson } from 'type-plus' + +type R = IsJson // true +type R = IsJson<{ a: readonly [1, 2] }> // true +type R = IsJson<{ a: Date }> // false +type R = IsJson<{ a: undefined }> // false +type R = IsJson // boolean + +type R = IsJson // string +type R = IsJson // false +``` -Two details follow from that. `undefined` is absent from `JsonPrimitive`, because JSON has no such value -and `JSON.stringify` drops the properties holding it. And every property of `JsonObject` is optional, so -reading one yields `JsonTypes | undefined` — the honest type for parsed data, where no key is guaranteed -to be there. +They take the `selection`, `distributive` and special-type branch options of the other predicates. See +[Type branching](/type-plus/api/type-branching/). To find out *where* a type fails, constrain with +`JsonCompatible`: its error names the path. ## Deprecated `JSON*` names @@ -49,10 +117,16 @@ name does. See [Migrating to v8](/type-plus/guides/migrating-to-v8/#deprecated-i | Type | Description | | --- | --- | -| `JsonTypes` | any value JSON can hold | +| `JsonTypes` | any value `JSON.parse` can produce | | `JsonPrimitive` | `boolean \| number \| string \| null` | -| `JsonObject` | string keys holding `JsonTypes`, all optional | -| `JsonArray` | `Array` | +| `JsonObject` | string keys holding `JsonTypes`, never `undefined` | +| `JsonArray` | `readonly JsonTypes[]` | +| `JsonCompatible` | `T` checked part by part; constrain with `T extends JsonCompatible` | +| `IsJson` | whether `T` survives a JSON round trip | +| `IsNotJson` | whether `T` does not survive a JSON round trip | | `JSONTypes`, `JSONPrimitive`, `JSONObject`, `JSONArray` | 💀 deprecated since 8.0.0, use the `Json*` names | -Source: [`src/json.ts`](https://github.com/cyberuni/type-plus/blob/main/packages/type-plus/src/json.ts). +Source: [`src/json.ts`](https://github.com/cyberuni/type-plus/blob/main/packages/type-plus/src/json.ts), +[`src/json-compatible.ts`](https://github.com/cyberuni/type-plus/blob/main/packages/type-plus/src/json-compatible.ts), +[`src/is-json.ts`](https://github.com/cyberuni/type-plus/blob/main/packages/type-plus/src/is-json.ts) and +[`src/is-not-json.ts`](https://github.com/cyberuni/type-plus/blob/main/packages/type-plus/src/is-not-json.ts). diff --git a/apps/website/src/content/docs/guides/migrating-to-v8.mdx b/apps/website/src/content/docs/guides/migrating-to-v8.mdx index 4d282350e0..41a5c43fda 100644 --- a/apps/website/src/content/docs/guides/migrating-to-v8.mdx +++ b/apps/website/src/content/docs/guides/migrating-to-v8.mdx @@ -449,6 +449,7 @@ Replace `assertType.as(value)` with `value satisfies T`. | `drop()` | none. The `DropMatch` type stays. | | `reduceKey()` | `reduceByKey()` | | `unpartial()` | import `unpartial` from the `unpartial` package | +| `JsonTypes.get()` | read the property and cast it. `type-plus` exported `JsonTypes` as a type only, so `get` was never reachable from the package entry. | `required()` and `requiredDeep()` stay. @@ -621,6 +622,32 @@ type R = RequiredPick A non-union `T` gives the same properties as before. +### JSON types + +`JsonObject` rejects a property that holds `undefined`, and `JsonArray` accepts readonly arrays. + +```ts +// 7.x +const a: JsonTypes = { a: undefined } // compiled +const b: JsonTypes = { a: [1, 2] } as const // error: readonly array + +// 8.0 +const a: JsonTypes = { a: undefined } // error +const b: JsonTypes = { a: [1, 2] } as const // compiles +``` + +JSON has no `undefined`, and `JSON.stringify` drops the property, so the value did not survive the round trip. +An optional property, `{ a?: number }`, is still accepted. + +`JsonObject` is now `{ [key: string]: JsonTypes }`, so reading a key is `JsonTypes | undefined` only under +`noUncheckedIndexedAccess`. 7.x made every key optional, which is what let `undefined` in. +Turn the option on if you relied on that read. + +`JsonArray` is now `readonly JsonTypes[]`. Code that mutates a value typed as `JsonArray` needs a copy or a cast. + +To accept an `interface` as JSON, constrain with `T extends JsonCompatible`. An interface was never assignable to +`JsonTypes`, because TypeScript gives it no implicit index signature. + ### Other results - `LeftJoin` keeps the `?` and `readonly` modifiers of `A`. @@ -657,5 +684,6 @@ These additions need no migration, but some replace a workaround you may have wr - `Divide`, `Quotient`, `Remainder`, `LessThan`, `GreaterThanOrEqual`, `LessThanOrEqual`, `Min`, `Slice`, `ArrayPlus.Join`, and `StringPlus.StartsWith`, `EndsWith`, `Replace` and `ReplaceAll`. - The missing negations, such as `IsNotUnion`, `IsNotLiteral`, `HasNoKey` and `HasNoNull`. +- `JsonCompatible`, `IsJson` and `IsNotJson` accept an `interface` as JSON, and the `JsonCompatible` error names the path of the part that is not JSON. The [changelog](https://github.com/cyberuni/type-plus/blob/main/packages/type-plus/CHANGELOG.md) has the full list. diff --git a/packages/type-plus/llms.txt b/packages/type-plus/llms.txt index cedc3034b3..cb199c078f 100644 --- a/packages/type-plus/llms.txt +++ b/packages/type-plus/llms.txt @@ -1,6 +1,6 @@ # type-plus -> 270 utility types and functions for TypeScript, most of which resolve at the type level and emit no runtime code. This file is generated from the exported surface of `type-plus` and `type-plus/testing`; edit `packages/type-plus/scripts/generate-llms-txt.mjs`, not this file. +> 273 utility types and functions for TypeScript, most of which resolve at the type level and emit no runtime code. This file is generated from the exported surface of `type-plus` and `type-plus/testing`; edit `packages/type-plus/scripts/generate-llms-txt.mjs`, not this file. Most of `type-plus` is types, not values. Importing a type costs nothing at runtime -- it is erased at compile time, and the shipped declarations are several times the size of the shipped JavaScript. Import types with `import type` and the rest with a plain `import`. @@ -13,7 +13,7 @@ Four conventions govern almost every type in the library. Knowing them is worth - `{ distributive: false }` turns off distribution over unions. By default a check applied to `A | B` is applied to each member and the results are unioned; with distribution off the union is judged as a whole. - Every branch of a check is overridable through the same options object: `IsNever`, and likewise `$unknown`, `$then` and `$else`. Passing the `Is*.$Branch` selector instead -- `IsNever` -- yields the `$Then` / `$Else` markers, which is what makes a check composable inside another type. -The authoritative, per-symbol detail is the TSDoc in the shipped `.d.ts` files, under `node_modules/type-plus/esm/`. Every export declared in this package carries TSDoc (270 of the 270). A documented type states what it resolves to in `@example` blocks. Read the declaration before guessing at a signature. +The authoritative, per-symbol detail is the TSDoc in the shipped `.d.ts` files, under `node_modules/type-plus/esm/`. Every export declared in this package carries TSDoc (273 of the 273). A documented type states what it resolves to in `@example` blocks. Read the declaration before guessing at a signature. ## Guides @@ -34,7 +34,7 @@ The authoritative, per-symbol detail is the TSDoc in the shipped `.d.ts` files, - [Boolean and Logical](https://cyberuni.github.io/type-plus/api/boolean/): 11 exports from `src/binary/`, `src/boolean/`, `src/logical/` -- And, Bit, IsBoolean, IsFalse, IsNotBoolean, IsNotFalse, IsNotTrue, IsTrue, Not, Or, Xor. - [Equality](https://cyberuni.github.io/type-plus/api/equality/): 2 exports from `src/equal/` -- IsEqual, IsNotEqual. - [Function and Functional](https://cyberuni.github.io/type-plus/api/function/): 13 exports from `src/class/`, `src/function/`, `src/functional/` -- AnyConstructor, AnyFunction, ChainFn, ContextBaseShape, ContextBuilder, ContextExtender, EndoFn, ExtractFunction, IsFunction, IsNotFunction, compose, context, extractFunction. -- [JSON](https://cyberuni.github.io/type-plus/api/json/): 8 exports from `src/json.ts` -- JSONArray, JSONObject, JSONPrimitive, JSONTypes, JsonArray, JsonObject, JsonPrimitive, JsonTypes. +- [JSON](https://cyberuni.github.io/type-plus/api/json/): 11 exports from `src/is-json.ts`, `src/is-not-json.ts`, `src/json-compatible.ts`, `src/json.ts` -- IsJson, IsNotJson, JSONArray, JSONObject, JSONPrimitive, JSONTypes, JsonArray, JsonCompatible, JsonObject, JsonPrimitive, JsonTypes. - [Math](https://cyberuni.github.io/type-plus/api/math/): 16 exports from `src/math/` -- Abs, Add, Decrement, Divide, GreaterThan, GreaterThanOrEqual, Increment, LessThan, LessThanOrEqual, MathPlus, Max, Min, Multiply, Quotient, Remainder, Subtract. - [Node.js](https://cyberuni.github.io/type-plus/api/nodejs/): 3 exports from `src/nodejs/` -- SystemErrorCodes, SystemErrors, isSystemError. - [Nominal Types](https://cyberuni.github.io/type-plus/api/nominal/): 8 exports from `src/nominal/` -- Brand, Branded, Flavor, Flavored, FlavoredUnit, brand, flavor, nominalMatch. diff --git a/packages/type-plus/scripts/generate-llms-txt.mjs b/packages/type-plus/scripts/generate-llms-txt.mjs index 6683c15338..b927b0c8d5 100644 --- a/packages/type-plus/scripts/generate-llms-txt.mjs +++ b/packages/type-plus/scripts/generate-llms-txt.mjs @@ -55,7 +55,10 @@ function write(stream, message) { const FAMILY_DOCS = { $type: { title: 'Type branching', page: 'api/type-branching' }, 'composable-types.ts': { title: 'Type Sets', page: 'api/type-sets' }, + 'is-json.ts': { title: 'JSON', page: 'api/json' }, + 'is-not-json.ts': { title: 'JSON', page: 'api/json' }, 'json.ts': { title: 'JSON', page: 'api/json' }, + 'json-compatible.ts': { title: 'JSON', page: 'api/json' }, 'primitive.ts': { title: 'Type Sets', page: 'api/type-sets' }, 'union-keys.ts': { title: 'Type Sets', page: 'api/type-sets' }, 'unpartial.ts': { title: 'Object', page: 'api/object' },