Skip to content

Commit

Permalink
Make the Jsonify type more correct (#410)
Browse files Browse the repository at this point in the history
  • Loading branch information
frontsideair committed Jun 30, 2022
1 parent 2f418db commit 8ca2959
Show file tree
Hide file tree
Showing 3 changed files with 107 additions and 30 deletions.
29 changes: 17 additions & 12 deletions source/jsonify.d.ts
@@ -1,14 +1,9 @@
import type {JsonPrimitive, JsonValue} from './basic';
import {Finite, NegativeInfinity, PositiveInfinity} from './numeric';
import {TypedArray} from './typed-array';

// Note: The return value has to be `any` and not `unknown` so it can match `void`.
type NotJsonable = ((...args: any[]) => any) | undefined;

// Note: Handles special case where Arrays with `undefined` are transformed to `'null'` by `JSON.stringify()`
// Only use with array members
type JsonifyArrayMember<T> =
T extends undefined ?
null | Exclude<T, undefined> :
Jsonify<T>;
type NotJsonable = ((...args: any[]) => any) | undefined | symbol;

/**
Transform a type to one that is assignable to the `JsonValue` type.
Expand Down Expand Up @@ -71,16 +66,26 @@ type Jsonify<T> =
// Check if there are any non-JSONable types represented in the union.
// Note: The use of tuples in this first condition side-steps distributive conditional types
// (see https://github.com/microsoft/TypeScript/issues/29368#issuecomment-453529532)
[Extract<T, NotJsonable>] extends [never]
? T extends JsonPrimitive
[Extract<T, NotJsonable | BigInt>] extends [never]
? T extends PositiveInfinity | NegativeInfinity ? null
: T extends JsonPrimitive
? T // Primitive is acceptable
: T extends Number ? number
: T extends String ? string
: T extends Boolean ? boolean
: T extends Map<any, any> | Set<any> ? {}
: T extends TypedArray ? Record<string, number>
: T extends Array<infer U>
? Array<JsonifyArrayMember<U>> // It's an array: recursive call for its children
? Array<Jsonify<U extends NotJsonable ? null : U>> // It's an array: recursive call for its children
: T extends object
? 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<Required<T>[P]>} // It's an object: recursive call for its children
: {[P in keyof T as P extends symbol
? never
: T[P] extends NotJsonable
? never
: P]: Jsonify<Required<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
2 changes: 2 additions & 0 deletions source/numeric.d.ts
Expand Up @@ -34,6 +34,8 @@ You can't pass a `bigint` as they are already guaranteed to be finite.
Use-case: Validating and documenting parameters.
Note: This can't detect `NaN`, please upvote [this issue](https://github.com/microsoft/TypeScript/issues/28682) if you want to have this type as a built-in in TypeScript.
@example
```
import type {Finite} from 'type-fest';
Expand Down
106 changes: 88 additions & 18 deletions test-d/jsonify.ts
@@ -1,5 +1,5 @@
import {expectAssignable, expectNotAssignable, expectType} from 'tsd';
import type {Jsonify, JsonValue} from '..';
import type {Jsonify, JsonValue, NegativeInfinity, PositiveInfinity} from '..';

interface A {
a: number;
Expand Down Expand Up @@ -123,17 +123,93 @@ const nonJsonWithInvalidToJSON = new NonJsonWithInvalidToJSON();
expectNotAssignable<JsonValue>(nonJsonWithInvalidToJSON);
expectNotAssignable<JsonValue>(nonJsonWithInvalidToJSON.toJSON());

// Special cases of Array with `undefined` member
// `[undefined]` is not JSON because it contains non JSON value `undefined`
// However `JSON.parse(JSON.stringify())` transforms array members of `undefined` to `null`
expectNotAssignable<JsonValue>([undefined]);
declare const parsedStringifiedArrayWithUndefined1: Jsonify<undefined[]>; // = JSON.parse(JSON.stringify([undefined]));
expectType<null[]>(parsedStringifiedArrayWithUndefined1);
expectAssignable<JsonValue>(parsedStringifiedArrayWithUndefined1);
expectNotAssignable<JsonValue>([undefined, 1]);
declare const parsedStringifiedArrayWithUndefined2: Jsonify<[undefined, number]>;
expectType<Array<number | null>>(parsedStringifiedArrayWithUndefined2);
expectAssignable<JsonValue>(parsedStringifiedArrayWithUndefined2);
// Not jsonable types; these types behave differently when used as plain values, as members of arrays and as values of objects
declare const undefined: undefined;
expectNotAssignable<JsonValue>(undefined);

declare const fn: (_: any) => void;
expectNotAssignable<JsonValue>(fn);

declare const symbol: symbol;
expectNotAssignable<JsonValue>(symbol);

// Plain values fail JSON.stringify()
declare const plainUndefined: Jsonify<typeof undefined>;
expectType<never>(plainUndefined);

declare const plainFn: Jsonify<typeof fn>;
expectType<never>(plainFn);

declare const plainSymbol: Jsonify<typeof symbol>;
expectType<never>(plainSymbol);

// Array members become null
declare const arrayMemberUndefined: Jsonify<Array<typeof undefined>>;
expectType<null[]>(arrayMemberUndefined);

declare const arrayMemberFn: Jsonify<Array<typeof fn>>;
expectType<null[]>(arrayMemberFn);

declare const arrayMemberSymbol: Jsonify<Array<typeof symbol>>;
expectType<null[]>(arrayMemberSymbol);

// When used in object values, these keys are filtered
declare const objectValueUndefined: Jsonify<{keep: string; undefined: typeof undefined}>;
expectType<{keep: string}>(objectValueUndefined);

declare const objectValueFn: Jsonify<{keep: string; fn: typeof fn}>;
expectType<{keep: string}>(objectValueFn);

declare const objectValueSymbol: Jsonify<{keep: string; symbol: typeof symbol}>;
expectType<{keep: string}>(objectValueSymbol);

// Symbol keys are filtered
declare const objectKeySymbol: Jsonify<{[key: typeof symbol]: number; keep: string}>;
expectType<{keep: string}>(objectKeySymbol);

// Number, String and Boolean values are turned into primitive counterparts
declare const number: Number;
expectNotAssignable<JsonValue>(number);

declare const string: String;
expectNotAssignable<JsonValue>(string);

declare const boolean: Boolean;
expectNotAssignable<JsonValue>(boolean);

declare const numberJson: Jsonify<typeof number>;
expectType<number>(numberJson);

declare const stringJson: Jsonify<typeof string>;
expectType<string>(stringJson);

declare const booleanJson: Jsonify<typeof boolean>;
expectType<boolean>(booleanJson);

// BigInt fails JSON.stringify
declare const bigInt: Jsonify<BigInt>;
expectType<never>(bigInt);

declare const int8Array: Int8Array;
declare const int8ArrayJson: Jsonify<typeof int8Array>;
expectType<Record<string, number>>(int8ArrayJson);

declare const map: Map<string, number>;
declare const mapJson: Jsonify<typeof map>;
expectType<{}>(mapJson);

declare const set: Set<string>;
declare const setJson: Jsonify<typeof set>;
expectType<{}>(setJson);

// Positive and negative Infinity, NaN and null are turned into null
// NOTE: NaN is not detectable in TypeScript, so it is not tested; see https://github.com/sindresorhus/type-fest/issues/406
declare const positiveInfinity: PositiveInfinity;
declare const positiveInfJson: Jsonify<typeof positiveInfinity>;
expectType<null>(positiveInfJson);
declare const negativeInf: NegativeInfinity;
declare const negativeInfJson: Jsonify<typeof negativeInf>;
expectType<null>(negativeInfJson);

// Test that optional type members are not discarded wholesale.
interface OptionalPrimitive {
Expand All @@ -144,20 +220,14 @@ interface OptionalTypeUnion {
a?: string | (() => any);
}

interface OptionalFunction {
a?: () => any;
}

interface NonOptionalTypeUnion {
a: string | undefined;
}

declare const jsonifiedOptionalPrimitive: Jsonify<OptionalPrimitive>;
declare const jsonifiedOptionalTypeUnion: Jsonify<OptionalTypeUnion>;
declare const jsonifiedOptionalFunction: Jsonify<OptionalFunction>;
declare const jsonifiedNonOptionalTypeUnion: Jsonify<NonOptionalTypeUnion>;

expectType<{a?: string}>(jsonifiedOptionalPrimitive);
expectType<{a?: never}>(jsonifiedOptionalTypeUnion);
expectType<{a?: never}>(jsonifiedOptionalFunction);
expectType<{a: never}>(jsonifiedNonOptionalTypeUnion);

0 comments on commit 8ca2959

Please sign in to comment.