Skip to content

Commit 1ef2706

Browse files
committed
feat(isCustomJsonable): add isCustomJsonable function
1 parent d2075df commit 1ef2706

File tree

5 files changed

+199
-0
lines changed

5 files changed

+199
-0
lines changed

deno.jsonc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
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",

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/mod.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { isArrayOf } from "./array_of.ts";
55
import { isAsyncFunction } from "./async_function.ts";
66
import { isBigint } from "./bigint.ts";
77
import { isBoolean } from "./boolean.ts";
8+
import { isCustomJsonable } from "./custom_jsonable.ts";
89
import { isFunction } from "./function.ts";
910
import { isInstanceOf } from "./instance_of.ts";
1011
import { isIntersectionOf } from "./intersection_of.ts";
@@ -45,6 +46,7 @@ export * from "./array_of.ts";
4546
export * from "./async_function.ts";
4647
export * from "./bigint.ts";
4748
export * from "./boolean.ts";
49+
export * from "./custom_jsonable.ts";
4850
export * from "./function.ts";
4951
export * from "./instance_of.ts";
5052
export * from "./intersection_of.ts";
@@ -173,6 +175,25 @@ export const is: {
173175
* ```
174176
*/
175177
Boolean: typeof isBoolean;
178+
/**
179+
* Returns true if `x` is {@linkcode CustomJsonable}, false otherwise.
180+
*
181+
* Use {@linkcode [is/jsonable].isJsonable|isJsonable} to check if the type of `x` is a JSON-serializable.
182+
*
183+
* ```ts
184+
* import { is, CustomJsonable } from "@core/unknownutil";
185+
*
186+
* const a: unknown = Object.assign(42n, {
187+
* toJSON() {
188+
* return `${this}n`;
189+
* }
190+
* });
191+
* if (is.CustomJsonable(a)) {
192+
* const _: CustomJsonable = a;
193+
* }
194+
* ```
195+
*/
196+
CustomJsonable: typeof isCustomJsonable;
176197
/**
177198
* Return `true` if the type of `x` is `function`.
178199
*
@@ -1005,6 +1026,7 @@ export const is: {
10051026
AsyncFunction: isAsyncFunction,
10061027
Bigint: isBigint,
10071028
Boolean: isBoolean,
1029+
CustomJsonable: isCustomJsonable,
10081030
Function: isFunction,
10091031
InstanceOf: isInstanceOf,
10101032
IntersectionOf: isIntersectionOf,

0 commit comments

Comments
 (0)