Skip to content

Commit

Permalink
Accept non-JSON types with .toJSON() method in Jsonify type (#257)
Browse files Browse the repository at this point in the history
Co-authored-by: Sindre Sorhus <sindresorhus@gmail.com>
  • Loading branch information
darcyparker and sindresorhus committed Sep 10, 2021
1 parent a001611 commit 09fb092
Show file tree
Hide file tree
Showing 2 changed files with 68 additions and 2 deletions.
24 changes: 22 additions & 2 deletions source/jsonify.d.ts
@@ -1,11 +1,15 @@
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;

/**
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`.
Expand Down Expand Up @@ -36,6 +40,18 @@ fixedFn(point); // Good: point is assignable. Jsonify<T> transforms Geometry int
fixedFn(new Date()); // Error: As expected, Date is not assignable. Jsonify<T> 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<typeof time>` is equivalent to `{timeValue: string}`
const timeJson = JSON.parse(JSON.stringify(time)) as Jsonify<typeof time>;
```
@link https://github.com/Microsoft/TypeScript/issues/1897#issuecomment-710744173
@category Utilities
Expand All @@ -50,6 +66,10 @@ type Jsonify<T> =
: T extends Array<infer U>
? Array<Jsonify<U>> // It's an array: recursive call for its children
: T extends object
? {[P in keyof T]: Jsonify<T[P]>} // 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<T[P]>} // 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
46 changes: 46 additions & 0 deletions test-d/jsonify.ts
Expand Up @@ -76,3 +76,49 @@ const point: Geometry = {

expectNotAssignable<JsonValue>(point);
expectAssignable<Jsonify<Geometry>>(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<Date>;
expectAssignable<string>(dateToJSON);
expectAssignable<JsonValue>(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<X>` if the `@typescript-eslint/no-unsafe-assignment` ESLint rule is ignored
// or an `as Jsonify<X>` 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<X>; // = JSON.parse(JSON.stringify(x));
expectAssignable<JsonValue>(parsedStringifiedX);
expectAssignable<string>(parsedStringifiedX.a);

class NonJsonWithToJSON {
public fixture: Map<string, number> = new Map([['a', 1], ['b', 2]]);

public toJSON(): {fixture: Array<[string, number]>} {
return {
fixture: [...this.fixture.entries()],
};
}
}
const nonJsonWithToJSON = new NonJsonWithToJSON();
expectNotAssignable<JsonValue>(nonJsonWithToJSON);
expectAssignable<JsonValue>(nonJsonWithToJSON.toJSON());
expectAssignable<Jsonify<NonJsonWithToJSON>>(nonJsonWithToJSON.toJSON());

class NonJsonWithInvalidToJSON {
public fixture: Map<string, number> = 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<string, number>} {
return {
fixture: this.fixture,
};
}
}

const nonJsonWithInvalidToJSON = new NonJsonWithInvalidToJSON();
expectNotAssignable<JsonValue>(nonJsonWithInvalidToJSON);
expectNotAssignable<JsonValue>(nonJsonWithInvalidToJSON.toJSON());

0 comments on commit 09fb092

Please sign in to comment.