Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/fix-json-types.md
Original file line number Diff line number Diff line change
@@ -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<T>`: constrain with `T extends JsonCompatible<T>` 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<T>` and `IsNotJson<T>` 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<T>(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<T>`.
6 changes: 3 additions & 3 deletions apps/website/public/llms.txt
Original file line number Diff line number Diff line change
@@ -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`.

Expand All @@ -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<any, { $any: 'any' }>`, and likewise `$unknown`, `$then` and `$else`. Passing the `Is*.$Branch` selector instead -- `IsNever<T, IsNever.$Branch>` -- 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

Expand All @@ -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.
Expand Down
106 changes: 90 additions & 16 deletions apps/website/src/content/docs/api/json.md
Original file line number Diff line number Diff line change
@@ -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
---
Expand All @@ -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<JsonTypes>
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<T>
```

🧰 *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<T extends JsonCompatible<T>>(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<T, $O = {}>
type IsNotJson<T, $O = {}>
```

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

```ts
import type { IsJson } from 'type-plus'

type R = IsJson<User> // true
type R = IsJson<{ a: readonly [1, 2] }> // true
type R = IsJson<{ a: Date }> // false
type R = IsJson<{ a: undefined }> // false
type R = IsJson<string | Date> // boolean

type R = IsJson<string | Date, { selection: 'filter' }> // string
type R = IsJson<string | Date, { distributive: false }> // 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

Expand All @@ -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<JsonTypes>` |
| `JsonObject` | string keys holding `JsonTypes`, never `undefined` |
| `JsonArray` | `readonly JsonTypes[]` |
| `JsonCompatible<T>` | `T` checked part by part; constrain with `T extends JsonCompatible<T>` |
| `IsJson<T, $O>` | whether `T` survives a JSON round trip |
| `IsNotJson<T, $O>` | 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).
28 changes: 28 additions & 0 deletions apps/website/src/content/docs/guides/migrating-to-v8.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,7 @@ Replace `assertType.as<T>(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.

Expand Down Expand Up @@ -621,6 +622,32 @@ type R = RequiredPick<U, 'a'>

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<T>`. 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`.
Expand Down Expand Up @@ -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<T>`, `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.
34 changes: 34 additions & 0 deletions packages/type-plus/error-snapshots/probes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ import type {
IsUnion,
IsUnknown,
IsVoid,
JsonCompatible,
JsonTypes,
Not,
NotAssignable,
Or,
Expand Down Expand Up @@ -470,3 +472,35 @@ export const testType_canAssign_union_passes = tt.canAssign<number | string, num
export const testType_of_equal_fails = tt.of({ a: 1 }).equal<{ a: string }>(true)
export const testType_of_returns_equal_fails = tt.of((): number => 1).returns.equal<string>(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<T extends JsonCompatible<T>>(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)
33 changes: 33 additions & 0 deletions packages/type-plus/error-snapshots/snapshot.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProbeJsonPost>'.
Type 'ProbeJsonPost' is not assignable to type '_JsonMap<ProbeJsonPost, "$"> & object & { [Symbol.hasInstance]?: undefined; }'.
Type 'ProbeJsonPost' is not assignable to type '_JsonMap<ProbeJsonPost, "$">'.
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'.
Loading
Loading