Skip to content

Commit 6f1acaa

Browse files
authored
Merge pull request #128 from jsr-core/add-is-jsonable
feat: add `isCustomJsonable` and `isJsonable`
2 parents d2075df + 39e4eb1 commit 6f1acaa

File tree

8 files changed

+481
-0
lines changed

8 files changed

+481
-0
lines changed

deno.jsonc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@
1515
"./is/async-function": "./is/async_function.ts",
1616
"./is/bigint": "./is/bigint.ts",
1717
"./is/boolean": "./is/boolean.ts",
18+
"./is/custom-jsonable": "./is/custom_jsonable.ts",
1819
"./is/function": "./is/function.ts",
1920
"./is/instance-of": "./is/instance_of.ts",
2021
"./is/intersection-of": "./is/intersection_of.ts",
22+
"./is/jsonable": "./is/jsonable.ts",
2123
"./is/literal-of": "./is/literal_of.ts",
2224
"./is/literal-one-of": "./is/literal_one_of.ts",
2325
"./is/map": "./is/map.ts",

is/custom_jsonable.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* Represents an object that has a custom `toJSON` method.
3+
*
4+
* Note that `string`, `number`, `boolean`, and `symbol` are not `CustomJsonable` even
5+
* if it's class prototype defines `toJSON` method.
6+
*
7+
* See {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#tojson_behavior|toJSON() behavior} of `JSON.stringify()` for more information.
8+
*/
9+
export type CustomJsonable = {
10+
toJSON(key: string | number): unknown;
11+
};
12+
13+
/**
14+
* Returns true if `x` is {@linkcode CustomJsonable}, false otherwise.
15+
*
16+
* Use {@linkcode [is/jsonable].isJsonable|isJsonable} to check if the type of `x` is a JSON-serializable.
17+
*
18+
* ```ts
19+
* import { is, CustomJsonable } from "@core/unknownutil";
20+
*
21+
* const a: unknown = Object.assign(42n, {
22+
* toJSON() {
23+
* return `${this}n`;
24+
* }
25+
* });
26+
* if (is.CustomJsonable(a)) {
27+
* const _: CustomJsonable = a;
28+
* }
29+
* ```
30+
*/
31+
export function isCustomJsonable(x: unknown): x is CustomJsonable {
32+
if (x == null) return false;
33+
switch (typeof x) {
34+
case "bigint":
35+
case "object":
36+
case "function":
37+
return typeof (x as CustomJsonable).toJSON === "function";
38+
}
39+
return false;
40+
}

is/custom_jsonable_bench.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { assert } from "@std/assert";
2+
import { isCustomJsonable } from "./custom_jsonable.ts";
3+
4+
const repeats = Array.from({ length: 100 });
5+
const positive: unknown = { toJSON: () => "custom" };
6+
const negative: unknown = {};
7+
8+
Deno.bench({
9+
name: "current",
10+
fn() {
11+
assert(repeats.every(() => isCustomJsonable(positive)));
12+
},
13+
group: "isCustomJsonable (positive)",
14+
});
15+
16+
Deno.bench({
17+
name: "current",
18+
fn() {
19+
assert(repeats.every(() => !isCustomJsonable(negative)));
20+
},
21+
group: "isCustomJsonable (negative)",
22+
});

is/custom_jsonable_test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { assertEquals } from "@std/assert";
2+
import { isCustomJsonable } from "./custom_jsonable.ts";
3+
4+
export function buildTestcases() {
5+
return [
6+
["undefined", undefined],
7+
["null", null],
8+
["string", ""],
9+
["number", 0],
10+
["boolean", true],
11+
["array", []],
12+
["object", {}],
13+
["bigint", 0n],
14+
["function", () => {}],
15+
["symbol", Symbol()],
16+
] as const satisfies readonly (readonly [name: string, value: unknown])[];
17+
}
18+
19+
Deno.test("isCustomJsonable", async (t) => {
20+
for (const [name, value] of buildTestcases()) {
21+
await t.step(`return false for ${name}`, () => {
22+
assertEquals(isCustomJsonable(value), false);
23+
});
24+
}
25+
26+
for (const [name, value] of buildTestcases()) {
27+
switch (name) {
28+
case "undefined":
29+
case "null":
30+
// Skip undefined, null that is not supported by Object.assign.
31+
continue;
32+
default:
33+
// Object.assign() doesn't make a value CustomJsonable.
34+
await t.step(
35+
`return false for ${name} even if it is wrapped by Object.assign()`,
36+
() => {
37+
assertEquals(
38+
isCustomJsonable(
39+
Object.assign(value as NonNullable<unknown>, { a: 0 }),
40+
),
41+
false,
42+
);
43+
},
44+
);
45+
}
46+
}
47+
48+
for (const [name, value] of buildTestcases()) {
49+
switch (name) {
50+
case "undefined":
51+
case "null":
52+
// Skip undefined, null that is not supported by Object.assign.
53+
continue;
54+
default:
55+
// toJSON method applied with Object.assign() makes a value CustomJsonable.
56+
await t.step(
57+
`return true for ${name} if it has own toJSON method`,
58+
() => {
59+
assertEquals(
60+
isCustomJsonable(
61+
Object.assign(value as NonNullable<unknown>, {
62+
toJSON: () => "custom",
63+
}),
64+
),
65+
true,
66+
);
67+
},
68+
);
69+
}
70+
}
71+
72+
for (const [name, value] of buildTestcases()) {
73+
switch (name) {
74+
case "undefined":
75+
case "null":
76+
// Skip undefined, null that does not have constructor.
77+
continue;
78+
case "string":
79+
case "number":
80+
case "boolean":
81+
case "symbol":
82+
// toJSON method defined in the class prototype does NOT make a value CustomJsonable if the value is
83+
// string, number, boolean, or symbol.
84+
// See https://tc39.es/ecma262/multipage/structured-data.html#sec-serializejsonproperty for details.
85+
await t.step(
86+
`return false for ${name} if the class prototype defines toJSON method`,
87+
() => {
88+
const proto = Object.getPrototypeOf(value);
89+
proto.toJSON = () => "custom";
90+
try {
91+
assertEquals(isCustomJsonable(value), false);
92+
} finally {
93+
delete proto.toJSON;
94+
}
95+
},
96+
);
97+
break;
98+
default:
99+
// toJSON method defined in the class prototype makes a value CustomJsonable.
100+
await t.step(
101+
`return true for ${name} if the class prototype defines toJSON method`,
102+
() => {
103+
const proto = Object.getPrototypeOf(value);
104+
proto.toJSON = () => "custom";
105+
try {
106+
assertEquals(isCustomJsonable(value), true);
107+
} finally {
108+
delete proto.toJSON;
109+
}
110+
},
111+
);
112+
}
113+
}
114+
});

is/jsonable.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { type CustomJsonable, isCustomJsonable } from "./custom_jsonable.ts";
2+
3+
/**
4+
* Represents a JSON-serializable value.
5+
*
6+
* See {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#description|Description} of `JSON.stringify()` for more information.
7+
*/
8+
export type Jsonable =
9+
| string
10+
| number
11+
| boolean
12+
| null
13+
| unknown[]
14+
| { [key: string]: unknown }
15+
| CustomJsonable;
16+
17+
/**
18+
* Returns true if `x` is a JSON-serializable value, false otherwise.
19+
*
20+
* It does not check array or object properties recursively.
21+
*
22+
* Use {@linkcode [is/custom_jsonable].isCustomJsonable|isCustomJsonable} to check if the type of `x` has a custom `toJSON` method.
23+
*
24+
* ```ts
25+
* import { is, Jsonable } from "@core/unknownutil";
26+
*
27+
* const a: unknown = "Hello, world!";
28+
* if (is.Jsonable(a)) {
29+
* const _: Jsonable = a;
30+
* }
31+
* ```
32+
*/
33+
export function isJsonable(x: unknown): x is Jsonable {
34+
switch (typeof x) {
35+
case "undefined":
36+
return false;
37+
case "string":
38+
case "number":
39+
case "boolean":
40+
return true;
41+
case "bigint":
42+
case "symbol":
43+
case "function":
44+
return isCustomJsonable(x);
45+
case "object": {
46+
if (x === null || Array.isArray(x)) return true;
47+
const p = Object.getPrototypeOf(x);
48+
if (p === BigInt.prototype || p === Function.prototype) {
49+
return isCustomJsonable(x);
50+
}
51+
return true;
52+
}
53+
}
54+
}

is/jsonable_bench.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { assert } from "@std/assert";
2+
import { isJsonable } from "./jsonable.ts";
3+
import { buildTestcases } from "./custom_jsonable_test.ts";
4+
5+
const repeats = Array.from({ length: 100 });
6+
7+
for (const [name, value] of buildTestcases()) {
8+
switch (name) {
9+
case "undefined":
10+
case "bigint":
11+
case "function":
12+
case "symbol":
13+
Deno.bench({
14+
name: "current",
15+
fn() {
16+
assert(repeats.every(() => !isJsonable(value)));
17+
},
18+
group: `isJsonable (${name})`,
19+
});
20+
break;
21+
default:
22+
Deno.bench({
23+
name: "current",
24+
fn() {
25+
assert(repeats.every(() => isJsonable(value)));
26+
},
27+
group: `isJsonable (${name})`,
28+
});
29+
}
30+
}
31+
32+
for (const [name, value] of buildTestcases()) {
33+
switch (name) {
34+
case "undefined":
35+
case "null":
36+
continue;
37+
case "bigint":
38+
case "function":
39+
Deno.bench({
40+
name: "current",
41+
fn() {
42+
const v = Object.assign(value as NonNullable<unknown>, {
43+
toJSON: () => "custom",
44+
});
45+
assert(repeats.every(() => isJsonable(v)));
46+
},
47+
group: `isJsonable (${name} with own toJSON method)`,
48+
});
49+
}
50+
}
51+
52+
for (const [name, value] of buildTestcases()) {
53+
switch (name) {
54+
case "bigint":
55+
case "function":
56+
Deno.bench({
57+
name: "current",
58+
fn() {
59+
const proto = Object.getPrototypeOf(value);
60+
proto.toJSON = () => "custom";
61+
try {
62+
assert(repeats.every(() => isJsonable(value)));
63+
} finally {
64+
delete proto.toJSON;
65+
}
66+
},
67+
group:
68+
`isJsonable (${name} with class prototype defines toJSON method)`,
69+
});
70+
}
71+
}

0 commit comments

Comments
 (0)