Skip to content

Commit 3227c4a

Browse files
committed
feat: add isCustomJsonable and isJsonable
1 parent d2075df commit 3227c4a

File tree

8 files changed

+457
-0
lines changed

8 files changed

+457
-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: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* Represents an object that has a custom `toJSON` method.
3+
*
4+
* 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.
5+
*/
6+
export type CustomJsonable = {
7+
toJSON(key: string | number): unknown;
8+
};
9+
10+
/**
11+
* Returns true if `x` has own custom `toJSON` method ({@linkcode CustomJsonable}), false otherwise.
12+
*
13+
* Use {@linkcode [is/jsonable].isJsonable|isJsonable} to check if the type of `x` is a JSON-serializable.
14+
*
15+
* ```ts
16+
* import { is, CustomJsonable } from "@core/unknownutil";
17+
*
18+
* const a: unknown = Object.assign(42n, {
19+
* toJSON() {
20+
* return `${this}n`;
21+
* }
22+
* });
23+
* if (is.CustomJsonable(a)) {
24+
* const _: CustomJsonable = a;
25+
* }
26+
* ```
27+
*/
28+
export function isCustomJsonable(x: unknown): x is CustomJsonable {
29+
return x != null && typeof (x as CustomJsonable).toJSON === "function";
30+
}

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: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { assertEquals } from "@std/assert";
2+
import { isCustomJsonable } from "./custom_jsonable.ts";
3+
4+
const testcases: [name: string, value: unknown][] = [
5+
undefined,
6+
null,
7+
"",
8+
0,
9+
true,
10+
[],
11+
{},
12+
0n,
13+
() => {},
14+
Symbol(),
15+
].map((x) => {
16+
const t = typeof x;
17+
switch (t) {
18+
case "object":
19+
if (x === null) {
20+
return ["null", x];
21+
} else if (Array.isArray(x)) {
22+
return ["array", x];
23+
}
24+
return ["object", x];
25+
}
26+
return [t, x];
27+
});
28+
29+
Deno.test("isCustomJsonable", async (t) => {
30+
for (const [name, value] of testcases) {
31+
await t.step(`return false for ${name}`, () => {
32+
assertEquals(isCustomJsonable(value), false);
33+
});
34+
}
35+
36+
for (const [name, value] of testcases) {
37+
switch (name) {
38+
// Skip undefined, null that is not supported by Object.assign.
39+
case "undefined":
40+
case "null":
41+
continue;
42+
}
43+
await t.step(
44+
`return false for ${name} even if it has wrapped by Object.assign`,
45+
() => {
46+
assertEquals(
47+
isCustomJsonable(
48+
Object.assign(value as NonNullable<unknown>, { a: 0 }),
49+
),
50+
false,
51+
);
52+
},
53+
);
54+
}
55+
56+
for (const [name, value] of testcases) {
57+
switch (name) {
58+
// Skip undefined, null that is not supported by Object.assign.
59+
case "undefined":
60+
case "null":
61+
continue;
62+
}
63+
await t.step(
64+
`return true for ${name} if it has own custom toJSON method`,
65+
() => {
66+
assertEquals(
67+
isCustomJsonable(
68+
Object.assign(value as NonNullable<unknown>, {
69+
toJSON: () => "custom",
70+
}),
71+
),
72+
true,
73+
);
74+
},
75+
);
76+
}
77+
78+
for (const [name, value] of testcases) {
79+
switch (name) {
80+
// Skip undefined, null that is not supported by Object.assign.
81+
case "undefined":
82+
case "null":
83+
continue;
84+
}
85+
await t.step(
86+
`return true for ${name} if it class defines custom toJSON method`,
87+
() => {
88+
// deno-lint-ignore no-explicit-any
89+
(value as any).constructor.prototype.toJSON = () => "custom";
90+
try {
91+
assertEquals(isCustomJsonable(value), true);
92+
} finally {
93+
// deno-lint-ignore no-explicit-any
94+
delete (value as any).constructor.prototype.toJSON;
95+
}
96+
},
97+
);
98+
}
99+
});

is/jsonable.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
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+
| Jsonable[]
14+
| { [key: string]: Jsonable }
15+
| CustomJsonable;
16+
17+
/**
18+
* Returns true if `x` is a JSON-serializable value, false otherwise.
19+
*
20+
* Use {@linkcode [is/custom_jsonable].isCustomJsonable|isCustomJsonable} to check if the type of `x` has a custom `toJSON` method.
21+
*
22+
* ```ts
23+
* import { is, Jsonable } from "@core/unknownutil";
24+
*
25+
* const a: unknown = "Hello, world!";
26+
* if (is.Jsonable(a)) {
27+
* const _: Jsonable = a;
28+
* }
29+
* ```
30+
*/
31+
export function isJsonable(x: unknown): x is Jsonable {
32+
switch (typeof x) {
33+
case "undefined":
34+
return false;
35+
case "string":
36+
case "number":
37+
case "boolean":
38+
return true;
39+
case "bigint":
40+
return isCustomJsonable(x);
41+
case "object": {
42+
if (x === null) return false;
43+
const p = Object.getPrototypeOf(x);
44+
if (p === BigInt.prototype || p === Symbol.prototype) {
45+
return isCustomJsonable(x);
46+
}
47+
return true;
48+
}
49+
case "symbol":
50+
case "function":
51+
return isCustomJsonable(x);
52+
}
53+
}

is/jsonable_bench.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { assert } from "@std/assert";
2+
import { isJsonable } from "./jsonable.ts";
3+
4+
const repeats = Array.from({ length: 100 });
5+
const testcases: [name: string, value: unknown][] = [
6+
undefined,
7+
null,
8+
"",
9+
0,
10+
true,
11+
[],
12+
{},
13+
0n,
14+
() => {},
15+
Symbol(),
16+
].map((x) => {
17+
const t = typeof x;
18+
switch (t) {
19+
case "object":
20+
if (x === null) {
21+
return ["null", x];
22+
} else if (Array.isArray(x)) {
23+
return ["array", x];
24+
}
25+
return ["object", x];
26+
}
27+
return [t, x];
28+
});
29+
30+
for (const [name, value] of testcases) {
31+
switch (name) {
32+
case "undefined":
33+
case "null":
34+
case "bigint":
35+
case "function":
36+
case "symbol":
37+
Deno.bench({
38+
name: "current",
39+
fn() {
40+
assert(repeats.every(() => !isJsonable(value)));
41+
},
42+
group: `isJsonable (${name})`,
43+
});
44+
break;
45+
default:
46+
Deno.bench({
47+
name: "current",
48+
fn() {
49+
assert(repeats.every(() => isJsonable(value)));
50+
},
51+
group: `isJsonable (${name})`,
52+
});
53+
}
54+
}
55+
56+
for (const [name, value] of testcases) {
57+
switch (name) {
58+
case "bigint":
59+
case "function":
60+
case "symbol":
61+
Deno.bench({
62+
name: "current",
63+
fn() {
64+
const v = Object.assign(value as NonNullable<unknown>, {
65+
toJSON: () => "custom",
66+
});
67+
assert(repeats.every(() => isJsonable(v)));
68+
},
69+
group: `isJsonable (${name} with own custom toJSON method)`,
70+
});
71+
}
72+
}
73+
74+
for (const [name, value] of testcases) {
75+
switch (name) {
76+
case "bigint":
77+
case "function":
78+
case "symbol":
79+
Deno.bench({
80+
name: "current",
81+
fn() {
82+
// deno-lint-ignore no-explicit-any
83+
(value as any).constructor.prototype.toJSON = () => "custom";
84+
try {
85+
assert(repeats.every(() => isJsonable(value)));
86+
} finally {
87+
// deno-lint-ignore no-explicit-any
88+
delete (value as any).constructor.prototype.toJSON;
89+
}
90+
},
91+
group: `isJsonable (${name} with class defines custom toJSON method)`,
92+
});
93+
}
94+
}

0 commit comments

Comments
 (0)