Skip to content

Commit

Permalink
feat(isCustomJsonable): add isCustomJsonable function
Browse files Browse the repository at this point in the history
  • Loading branch information
lambdalisue committed Aug 23, 2024
1 parent d2075df commit 1ef2706
Show file tree
Hide file tree
Showing 5 changed files with 199 additions and 0 deletions.
1 change: 1 addition & 0 deletions deno.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"./is/async-function": "./is/async_function.ts",
"./is/bigint": "./is/bigint.ts",
"./is/boolean": "./is/boolean.ts",
"./is/custom-jsonable": "./is/custom_jsonable.ts",
"./is/function": "./is/function.ts",
"./is/instance-of": "./is/instance_of.ts",
"./is/intersection-of": "./is/intersection_of.ts",
Expand Down
40 changes: 40 additions & 0 deletions is/custom_jsonable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Represents an object that has a custom `toJSON` method.
*
* Note that `string`, `number`, `boolean`, and `symbol` are not `CustomJsonable` even
* if it's class prototype defines `toJSON` method.
*
* 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.
*/
export type CustomJsonable = {
toJSON(key: string | number): unknown;
};

/**
* Returns true if `x` is {@linkcode CustomJsonable}, false otherwise.
*
* Use {@linkcode [is/jsonable].isJsonable|isJsonable} to check if the type of `x` is a JSON-serializable.
*
* ```ts
* import { is, CustomJsonable } from "@core/unknownutil";
*
* const a: unknown = Object.assign(42n, {
* toJSON() {
* return `${this}n`;
* }
* });
* if (is.CustomJsonable(a)) {
* const _: CustomJsonable = a;
* }
* ```
*/
export function isCustomJsonable(x: unknown): x is CustomJsonable {
if (x == null) return false;
switch (typeof x) {
case "bigint":
case "object":
case "function":
return typeof (x as CustomJsonable).toJSON === "function";
}
return false;
}
22 changes: 22 additions & 0 deletions is/custom_jsonable_bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { assert } from "@std/assert";
import { isCustomJsonable } from "./custom_jsonable.ts";

const repeats = Array.from({ length: 100 });
const positive: unknown = { toJSON: () => "custom" };
const negative: unknown = {};

Deno.bench({
name: "current",
fn() {
assert(repeats.every(() => isCustomJsonable(positive)));
},
group: "isCustomJsonable (positive)",
});

Deno.bench({
name: "current",
fn() {
assert(repeats.every(() => !isCustomJsonable(negative)));
},
group: "isCustomJsonable (negative)",
});
114 changes: 114 additions & 0 deletions is/custom_jsonable_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { assertEquals } from "@std/assert";
import { isCustomJsonable } from "./custom_jsonable.ts";

export function buildTestcases() {
return [
["undefined", undefined],
["null", null],
["string", ""],
["number", 0],
["boolean", true],
["array", []],
["object", {}],
["bigint", 0n],
["function", () => {}],
["symbol", Symbol()],
] as const satisfies readonly (readonly [name: string, value: unknown])[];
}

Deno.test("isCustomJsonable", async (t) => {
for (const [name, value] of buildTestcases()) {
await t.step(`return false for ${name}`, () => {
assertEquals(isCustomJsonable(value), false);
});
}

for (const [name, value] of buildTestcases()) {
switch (name) {
case "undefined":
case "null":
// Skip undefined, null that is not supported by Object.assign.
continue;
default:
// Object.assign() doesn't make a value CustomJsonable.
await t.step(
`return false for ${name} even if it is wrapped by Object.assign()`,
() => {
assertEquals(
isCustomJsonable(
Object.assign(value as NonNullable<unknown>, { a: 0 }),
),
false,
);
},
);
}
}

for (const [name, value] of buildTestcases()) {
switch (name) {
case "undefined":
case "null":
// Skip undefined, null that is not supported by Object.assign.
continue;
default:
// toJSON method applied with Object.assign() makes a value CustomJsonable.
await t.step(
`return true for ${name} if it has own toJSON method`,
() => {
assertEquals(
isCustomJsonable(
Object.assign(value as NonNullable<unknown>, {
toJSON: () => "custom",
}),
),
true,
);
},
);
}
}

for (const [name, value] of buildTestcases()) {
switch (name) {
case "undefined":
case "null":
// Skip undefined, null that does not have constructor.
continue;
case "string":
case "number":
case "boolean":
case "symbol":
// toJSON method defined in the class prototype does NOT make a value CustomJsonable if the value is
// string, number, boolean, or symbol.
// See https://tc39.es/ecma262/multipage/structured-data.html#sec-serializejsonproperty for details.
await t.step(
`return false for ${name} if the class prototype defines toJSON method`,
() => {
const proto = Object.getPrototypeOf(value);
proto.toJSON = () => "custom";
try {
assertEquals(isCustomJsonable(value), false);
} finally {
delete proto.toJSON;
}
},
);
break;
default:
// toJSON method defined in the class prototype makes a value CustomJsonable.
await t.step(
`return true for ${name} if the class prototype defines toJSON method`,
() => {
const proto = Object.getPrototypeOf(value);
proto.toJSON = () => "custom";
try {
assertEquals(isCustomJsonable(value), true);
} finally {
delete proto.toJSON;
}
},
);
}
}
});
22 changes: 22 additions & 0 deletions is/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { isArrayOf } from "./array_of.ts";
import { isAsyncFunction } from "./async_function.ts";
import { isBigint } from "./bigint.ts";
import { isBoolean } from "./boolean.ts";
import { isCustomJsonable } from "./custom_jsonable.ts";
import { isFunction } from "./function.ts";
import { isInstanceOf } from "./instance_of.ts";
import { isIntersectionOf } from "./intersection_of.ts";
Expand Down Expand Up @@ -45,6 +46,7 @@ export * from "./array_of.ts";
export * from "./async_function.ts";
export * from "./bigint.ts";
export * from "./boolean.ts";
export * from "./custom_jsonable.ts";
export * from "./function.ts";
export * from "./instance_of.ts";
export * from "./intersection_of.ts";
Expand Down Expand Up @@ -173,6 +175,25 @@ export const is: {
* ```
*/
Boolean: typeof isBoolean;
/**
* Returns true if `x` is {@linkcode CustomJsonable}, false otherwise.
*
* Use {@linkcode [is/jsonable].isJsonable|isJsonable} to check if the type of `x` is a JSON-serializable.
*
* ```ts
* import { is, CustomJsonable } from "@core/unknownutil";
*
* const a: unknown = Object.assign(42n, {
* toJSON() {
* return `${this}n`;
* }
* });
* if (is.CustomJsonable(a)) {
* const _: CustomJsonable = a;
* }
* ```
*/
CustomJsonable: typeof isCustomJsonable;
/**
* Return `true` if the type of `x` is `function`.
*
Expand Down Expand Up @@ -1005,6 +1026,7 @@ export const is: {
AsyncFunction: isAsyncFunction,
Bigint: isBigint,
Boolean: isBoolean,
CustomJsonable: isCustomJsonable,
Function: isFunction,
InstanceOf: isInstanceOf,
IntersectionOf: isIntersectionOf,
Expand Down

0 comments on commit 1ef2706

Please sign in to comment.