From 09fb092617c444f29078d5712049e4d5f822ed31 Mon Sep 17 00:00:00 2001 From: Darcy Parker Date: Fri, 10 Sep 2021 04:07:12 +0000 Subject: [PATCH] Accept non-JSON types with `.toJSON()` method in `Jsonify` type (#257) Co-authored-by: Sindre Sorhus --- source/jsonify.d.ts | 24 +++++++++++++++++++++-- test-d/jsonify.ts | 46 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/source/jsonify.d.ts b/source/jsonify.d.ts index 80b50c773..bc69492a8 100644 --- a/source/jsonify.d.ts +++ b/source/jsonify.d.ts @@ -1,4 +1,4 @@ -import {JsonPrimitive} from './basic'; +import {JsonPrimitive, JsonValue} from './basic'; // Note: The return value has to be `any` and not `unknown` so it can match `void`. type NotJsonable = ((...args: any[]) => any) | undefined; @@ -6,6 +6,10 @@ type NotJsonable = ((...args: any[]) => any) | undefined; /** Transform a type to one that is assignable to the `JsonValue` type. +This includes: +1. Transforming JSON `interface` to a `type` that is assignable to `JsonValue`. +2. Transforming non-JSON value that is *jsonable* to a type that is assignable to `JsonValue`, where *jsonable* means the non-JSON value implements the `.toJSON()` method that returns a value that is assignable to `JsonValue`. + @remarks An interface cannot be structurally compared to `JsonValue` because an interface can be re-opened to add properties that may not be satisfy `JsonValue`. @@ -36,6 +40,18 @@ fixedFn(point); // Good: point is assignable. Jsonify transforms Geometry int fixedFn(new Date()); // Error: As expected, Date is not assignable. Jsonify cannot transforms Date into value assignable to JsonValue ``` +Non-JSON values such as `Date` implement `.toJSON()`, so they can be transformed to a value assignable to `JsonValue`: + +@example +``` +const time = { + timeValue: new Date() +}; + +// `Jsonify` is equivalent to `{timeValue: string}` +const timeJson = JSON.parse(JSON.stringify(time)) as Jsonify; +``` + @link https://github.com/Microsoft/TypeScript/issues/1897#issuecomment-710744173 @category Utilities @@ -50,6 +66,10 @@ type Jsonify = : T extends Array ? Array> // It's an array: recursive call for its children : T extends object - ? {[P in keyof T]: Jsonify} // It's an object: recursive call for its children + ? T extends {toJSON(): infer J} + ? (() => J) extends (() => JsonValue) // Is J assignable to JsonValue? + ? J // Then T is Jsonable and its Jsonable value is J + : never // Not Jsonable because its toJSON() method does not return JsonValue + : {[P in keyof T]: Jsonify} // It's an object: recursive call for its children : never // Otherwise any other non-object is removed : never; // Otherwise non-JSONable type union was found not empty diff --git a/test-d/jsonify.ts b/test-d/jsonify.ts index 837de3ecb..691fdcd08 100644 --- a/test-d/jsonify.ts +++ b/test-d/jsonify.ts @@ -76,3 +76,49 @@ const point: Geometry = { expectNotAssignable(point); expectAssignable>(point); + +// The following const values are examples of values `v` that are not JSON, but are *jsonable* using +// `v.toJSON()` or `JSON.parse(JSON.stringify(v))` +declare const dateToJSON: Jsonify; +expectAssignable(dateToJSON); +expectAssignable(dateToJSON); + +// The following commented `= JSON.parse(JSON.stringify(x))` is an example of how `parsedStringifiedX` could be created. +// * Note that this would be an unsafe assignment because `JSON.parse()` returns type `any`. +// But by inspection `JSON.stringify(x)` will use `x.a.toJSON()`. So the `JSON.parse()` result can be +// assigned to `Jsonify` if the `@typescript-eslint/no-unsafe-assignment` ESLint rule is ignored +// or an `as Jsonify` is added. +// * This test is not about how `parsedStringifiedX` is created, but about its type, so the `const` value is declared. +declare const parsedStringifiedX: Jsonify; // = JSON.parse(JSON.stringify(x)); +expectAssignable(parsedStringifiedX); +expectAssignable(parsedStringifiedX.a); + +class NonJsonWithToJSON { + public fixture: Map = new Map([['a', 1], ['b', 2]]); + + public toJSON(): {fixture: Array<[string, number]>} { + return { + fixture: [...this.fixture.entries()], + }; + } +} +const nonJsonWithToJSON = new NonJsonWithToJSON(); +expectNotAssignable(nonJsonWithToJSON); +expectAssignable(nonJsonWithToJSON.toJSON()); +expectAssignable>(nonJsonWithToJSON.toJSON()); + +class NonJsonWithInvalidToJSON { + public fixture: Map = new Map([['a', 1], ['b', 2]]); + + // This is intentionally invalid `.toJSON()`. + // It is invalid because the result is not assignable to `JsonValue`. + public toJSON(): {fixture: Map} { + return { + fixture: this.fixture, + }; + } +} + +const nonJsonWithInvalidToJSON = new NonJsonWithInvalidToJSON(); +expectNotAssignable(nonJsonWithInvalidToJSON); +expectNotAssignable(nonJsonWithInvalidToJSON.toJSON());