Skip to content

Commit 39e4eb1

Browse files
committed
feat(isJsonable): add isJsonable function
1 parent 1ef2706 commit 39e4eb1

File tree

5 files changed

+282
-0
lines changed

5 files changed

+282
-0
lines changed

deno.jsonc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"./is/function": "./is/function.ts",
2020
"./is/instance-of": "./is/instance_of.ts",
2121
"./is/intersection-of": "./is/intersection_of.ts",
22+
"./is/jsonable": "./is/jsonable.ts",
2223
"./is/literal-of": "./is/literal_of.ts",
2324
"./is/literal-one-of": "./is/literal_one_of.ts",
2425
"./is/map": "./is/map.ts",

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+
}

is/jsonable_test.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { assertEquals } from "@std/assert";
2+
import { isJsonable } from "./jsonable.ts";
3+
import { buildTestcases } from "./custom_jsonable_test.ts";
4+
5+
Deno.test("isJsonable", async (t) => {
6+
for (const [name, value] of buildTestcases()) {
7+
switch (name) {
8+
case "undefined":
9+
case "bigint":
10+
case "function":
11+
case "symbol":
12+
await t.step(`return false for ${name}`, () => {
13+
assertEquals(isJsonable(value), false);
14+
});
15+
break;
16+
default:
17+
await t.step(`return true for ${name}`, () => {
18+
assertEquals(isJsonable(value), true);
19+
});
20+
}
21+
}
22+
23+
for (const [name, value] of buildTestcases()) {
24+
switch (name) {
25+
case "undefined":
26+
case "null":
27+
// Skip undefined, null that is not supported by Object.assign.
28+
continue;
29+
case "bigint":
30+
case "function":
31+
// Object.assign() doesn't make bigint, function Jsonable.
32+
await t.step(
33+
`return false for ${name} even if it is wrapped by Object.assign()`,
34+
() => {
35+
assertEquals(
36+
isJsonable(
37+
Object.assign(value as NonNullable<unknown>, { a: 0 }),
38+
),
39+
false,
40+
);
41+
},
42+
);
43+
break;
44+
default:
45+
// Object.assign() makes other values Jsonable.
46+
await t.step(
47+
`return true for ${name} if it is wrapped by Object.assign()`,
48+
() => {
49+
assertEquals(
50+
isJsonable(
51+
Object.assign(value as NonNullable<unknown>, { a: 0 }),
52+
),
53+
true,
54+
);
55+
},
56+
);
57+
}
58+
}
59+
60+
for (const [name, value] of buildTestcases()) {
61+
switch (name) {
62+
case "undefined":
63+
case "null":
64+
// Skip undefined, null that is not supported by Object.assign.
65+
continue;
66+
case "bigint":
67+
case "function":
68+
// toJSON method assigned with Object.assign() makes bigint, function Jsonable.
69+
default:
70+
// toJSON method assigned with Object.assign() makes other values Jsonable.
71+
await t.step(
72+
`return true for ${name} if it has own toJSON method`,
73+
() => {
74+
assertEquals(
75+
isJsonable(
76+
Object.assign(value as NonNullable<unknown>, {
77+
toJSON: () => "custom",
78+
}),
79+
),
80+
true,
81+
);
82+
},
83+
);
84+
}
85+
}
86+
87+
for (const [name, value] of buildTestcases()) {
88+
switch (name) {
89+
case "undefined":
90+
case "null":
91+
// Skip undefined, null that does not have prototype
92+
continue;
93+
case "symbol":
94+
// toJSON method defined in the class prototype does not make symbol Jsonable.
95+
await t.step(
96+
`return false for ${name} if the class prototype defines toJSON method`,
97+
() => {
98+
const proto = Object.getPrototypeOf(value);
99+
proto.toJSON = () => "custom";
100+
try {
101+
assertEquals(isJsonable(value), false);
102+
} finally {
103+
delete proto.toJSON;
104+
}
105+
},
106+
);
107+
break;
108+
case "bigint":
109+
case "function":
110+
// toJSON method defined in the class prototype makes bigint, function Jsonable.
111+
default:
112+
// toJSON method defined in the class prototype makes other values Jsonable.
113+
await t.step(
114+
`return true for ${name} if the class prototype defines toJSON method`,
115+
() => {
116+
const proto = Object.getPrototypeOf(value);
117+
proto.toJSON = () => "custom";
118+
try {
119+
assertEquals(isJsonable(value), true);
120+
} finally {
121+
delete proto.toJSON;
122+
}
123+
},
124+
);
125+
}
126+
}
127+
128+
await t.step(
129+
"returns true on circular reference (unwilling behavior)",
130+
() => {
131+
const circular = { a: {} };
132+
circular["a"] = circular;
133+
assertEquals(isJsonable(circular), true);
134+
},
135+
);
136+
});

is/mod.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { isCustomJsonable } from "./custom_jsonable.ts";
99
import { isFunction } from "./function.ts";
1010
import { isInstanceOf } from "./instance_of.ts";
1111
import { isIntersectionOf } from "./intersection_of.ts";
12+
import { isJsonable } from "./jsonable.ts";
1213
import { isLiteralOf } from "./literal_of.ts";
1314
import { isLiteralOneOf } from "./literal_one_of.ts";
1415
import { isMap } from "./map.ts";
@@ -50,6 +51,7 @@ export * from "./custom_jsonable.ts";
5051
export * from "./function.ts";
5152
export * from "./instance_of.ts";
5253
export * from "./intersection_of.ts";
54+
export * from "./jsonable.ts";
5355
export * from "./literal_of.ts";
5456
export * from "./literal_one_of.ts";
5557
export * from "./map.ts";
@@ -264,6 +266,23 @@ export const is: {
264266
* ```
265267
*/
266268
IntersectionOf: typeof isIntersectionOf;
269+
/**
270+
* Returns true if `x` is a JSON-serializable value, false otherwise.
271+
*
272+
* It does not check array or object properties recursively.
273+
*
274+
* Use {@linkcode [is/custom_jsonable].isCustomJsonable|isCustomJsonable} to check if the type of `x` has a custom `toJSON` method.
275+
*
276+
* ```ts
277+
* import { is, Jsonable } from "@core/unknownutil";
278+
*
279+
* const a: unknown = "Hello, world!";
280+
* if (is.Jsonable(a)) {
281+
* const _: Jsonable = a;
282+
* }
283+
* ```
284+
*/
285+
Jsonable: typeof isJsonable;
267286
/**
268287
* Return a type predicate function that returns `true` if the type of `x` is a literal type of `pred`.
269288
*
@@ -1030,6 +1049,7 @@ export const is: {
10301049
Function: isFunction,
10311050
InstanceOf: isInstanceOf,
10321051
IntersectionOf: isIntersectionOf,
1052+
Jsonable: isJsonable,
10331053
LiteralOf: isLiteralOf,
10341054
LiteralOneOf: isLiteralOneOf,
10351055
Map: isMap,

0 commit comments

Comments
 (0)