From f308e9a84df16578136dbd2a6f014d0924440f77 Mon Sep 17 00:00:00 2001 From: Alisue Date: Thu, 22 Aug 2024 04:13:27 +0900 Subject: [PATCH] feat: add `isCustomJsonable` and `isJsonable` --- is/custom_jsonable.ts | 32 ++++++++++ is/custom_jsonable_bench.ts | 22 +++++++ is/custom_jsonable_test.ts | 99 ++++++++++++++++++++++++++++++ is/jsonable.ts | 57 ++++++++++++++++++ is/jsonable_bench.ts | 94 +++++++++++++++++++++++++++++ is/jsonable_test.ts | 117 ++++++++++++++++++++++++++++++++++++ is/mod.ts | 20 ++++++ 7 files changed, 441 insertions(+) create mode 100644 is/custom_jsonable.ts create mode 100644 is/custom_jsonable_bench.ts create mode 100644 is/custom_jsonable_test.ts create mode 100644 is/jsonable.ts create mode 100644 is/jsonable_bench.ts create mode 100644 is/jsonable_test.ts diff --git a/is/custom_jsonable.ts b/is/custom_jsonable.ts new file mode 100644 index 0000000..bd78ee0 --- /dev/null +++ b/is/custom_jsonable.ts @@ -0,0 +1,32 @@ +/** + * Represents an object that has a custom `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` has own custom `toJSON` method ({@linkcode CustomJsonable}), false otherwise. + * + * Use {@linkcode [is/jsonable].isJsonable|isJsonable} to check if the type of `x` is a JSON-serializable. + * + * @param x The value to check. + * @returns `true` if `x` has own custom `toJSON` method, `false` otherwise. + * + * ```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 { + return x != null && typeof (x as CustomJsonable).toJSON === "function"; +} diff --git a/is/custom_jsonable_bench.ts b/is/custom_jsonable_bench.ts new file mode 100644 index 0000000..ffe633b --- /dev/null +++ b/is/custom_jsonable_bench.ts @@ -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)", +}); diff --git a/is/custom_jsonable_test.ts b/is/custom_jsonable_test.ts new file mode 100644 index 0000000..d1ff4ea --- /dev/null +++ b/is/custom_jsonable_test.ts @@ -0,0 +1,99 @@ +import { assertEquals } from "@std/assert"; +import { isCustomJsonable } from "./custom_jsonable.ts"; + +const testcases: [name: string, value: unknown][] = [ + undefined, + null, + "", + 0, + true, + [], + {}, + 0n, + () => {}, + Symbol(), +].map((x) => { + const t = typeof x; + switch (t) { + case "object": + if (x === null) { + return ["null", x]; + } else if (Array.isArray(x)) { + return ["array", x]; + } + return ["object", x]; + } + return [t, x]; +}); + +Deno.test("isCustomJsonable", async (t) => { + for (const [name, value] of testcases) { + await t.step(`return false for ${name}`, () => { + assertEquals(isCustomJsonable(value), false); + }); + } + + for (const [name, value] of testcases) { + switch (name) { + // Skip undefined, null that is not supported by Object.assign. + case "undefined": + case "null": + continue; + } + await t.step( + `return false for ${name} even if it has wrapped by Object.assign`, + () => { + assertEquals( + isCustomJsonable( + Object.assign(value as NonNullable, { a: 0 }), + ), + false, + ); + }, + ); + } + + for (const [name, value] of testcases) { + switch (name) { + // Skip undefined, null that is not supported by Object.assign. + case "undefined": + case "null": + continue; + } + await t.step( + `return true for ${name} if it has own custom toJSON method`, + () => { + assertEquals( + isCustomJsonable( + Object.assign(value as NonNullable, { + toJSON: () => "custom", + }), + ), + true, + ); + }, + ); + } + + for (const [name, value] of testcases) { + switch (name) { + // Skip undefined, null that is not supported by Object.assign. + case "undefined": + case "null": + continue; + } + await t.step( + `return true for ${name} if it class defines custom toJSON method`, + () => { + // deno-lint-ignore no-explicit-any + (value as any).constructor.prototype.toJSON = () => "custom"; + try { + assertEquals(isCustomJsonable(value), true); + } finally { + // deno-lint-ignore no-explicit-any + delete (value as any).constructor.prototype.toJSON; + } + }, + ); + } +}); diff --git a/is/jsonable.ts b/is/jsonable.ts new file mode 100644 index 0000000..ef8701c --- /dev/null +++ b/is/jsonable.ts @@ -0,0 +1,57 @@ +import { type CustomJsonable, isCustomJsonable } from "./custom_jsonable.ts"; + +/** + * Represents a JSON-serializable value. + * + * See {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#description|Description} of `JSON.stringify()` for more information. + */ +export type Jsonable = + | string + | number + | boolean + | null + | Jsonable[] + | { [key: string]: Jsonable } + | CustomJsonable; + +/** + * Returns true if `x` is a JSON-serializable value, false otherwise. + * + * Use {@linkcode [is/custom_jsonable].isCustomJsonable|isCustomJsonable} to check if the type of `x` has a custom `toJSON` method. + * + * @param x The value to check. + * @returns `true` if `x` is a JSON-serializable value, `false` otherwise. + * + * ```ts + * import { is, Jsonable } from "@core/unknownutil"; + * + * const a: unknown = "Hello, world!"; + * if (is.Jsonable(a)) { + * const _: Jsonable = a; + * } + */ +export function isJsonable(x: unknown): x is Jsonable { + switch (typeof x) { + case "undefined": + return false; + case "string": + case "number": + case "boolean": + return true; + case "bigint": + return isCustomJsonable(x); + case "object": { + if (x === null) return false; + const p = Object.getPrototypeOf(x); + if (p === BigInt.prototype || p === Symbol.prototype) { + return isCustomJsonable(x); + } + return true; + } + case "symbol": + case "function": + return isCustomJsonable(x); + default: + throw new Error(`Unexpected type: ${typeof x}`); + } +} diff --git a/is/jsonable_bench.ts b/is/jsonable_bench.ts new file mode 100644 index 0000000..5b3361f --- /dev/null +++ b/is/jsonable_bench.ts @@ -0,0 +1,94 @@ +import { assert } from "@std/assert"; +import { isJsonable } from "./jsonable.ts"; + +const repeats = Array.from({ length: 100 }); +const testcases: [name: string, value: unknown][] = [ + undefined, + null, + "", + 0, + true, + [], + {}, + 0n, + () => {}, + Symbol(), +].map((x) => { + const t = typeof x; + switch (t) { + case "object": + if (x === null) { + return ["null", x]; + } else if (Array.isArray(x)) { + return ["array", x]; + } + return ["object", x]; + } + return [t, x]; +}); + +for (const [name, value] of testcases) { + switch (name) { + case "undefined": + case "null": + case "bigint": + case "function": + case "symbol": + Deno.bench({ + name: "current", + fn() { + assert(repeats.every(() => !isJsonable(value))); + }, + group: `isJsonable (${name})`, + }); + break; + default: + Deno.bench({ + name: "current", + fn() { + assert(repeats.every(() => isJsonable(value))); + }, + group: `isJsonable (${name})`, + }); + } +} + +for (const [name, value] of testcases) { + switch (name) { + case "bigint": + case "function": + case "symbol": + Deno.bench({ + name: "current", + fn() { + const v = Object.assign(value as NonNullable, { + toJSON: () => "custom", + }); + assert(repeats.every(() => isJsonable(v))); + }, + group: `isJsonable (${name} with own custom toJSON method)`, + }); + } +} + +for (const [name, value] of testcases) { + switch (name) { + case "bigint": + case "function": + case "symbol": + Deno.bench({ + name: "current", + fn() { + // deno-lint-ignore no-explicit-any + (value as any).constructor.prototype.toJSON = () => "custom"; + try { + assert(repeats.every(() => isJsonable(value))); + } finally { + // deno-lint-ignore no-explicit-any + delete (value as any).constructor.prototype.toJSON; + } + }, + group: `isJsonable (${name} with class defines custom toJSON method)`, + }); + } +} diff --git a/is/jsonable_test.ts b/is/jsonable_test.ts new file mode 100644 index 0000000..c6233e9 --- /dev/null +++ b/is/jsonable_test.ts @@ -0,0 +1,117 @@ +import { assertEquals } from "@std/assert"; +import { isJsonable } from "./jsonable.ts"; + +const testcases: [name: string, value: unknown][] = [ + undefined, + null, + "", + 0, + true, + [], + {}, + 0n, + () => {}, + Symbol(), +].map((x) => { + const t = typeof x; + switch (t) { + case "object": + if (x === null) { + return ["null", x]; + } else if (Array.isArray(x)) { + return ["array", x]; + } + return ["object", x]; + } + return [t, x]; +}); + +Deno.test("isJsonable", async (t) => { + for (const [name, value] of testcases) { + switch (name) { + case "undefined": + case "null": + case "bigint": + case "function": + case "symbol": + await t.step(`return false for ${name}`, () => { + assertEquals(isJsonable(value), false); + }); + break; + default: + await t.step(`return true for ${name}`, () => { + assertEquals(isJsonable(value), true); + }); + } + } + + for (const [name, value] of testcases) { + switch (name) { + case "bigint": + case "function": + case "symbol": + await t.step( + `return false for ${name} even if it has wrapped by Object.assign`, + () => { + assertEquals( + isJsonable( + Object.assign(value as NonNullable, { a: 0 }), + ), + false, + ); + }, + ); + } + } + + for (const [name, value] of testcases) { + switch (name) { + case "bigint": + case "function": + case "symbol": + await t.step( + `return true for ${name} if it has own toJSON method`, + () => { + assertEquals( + isJsonable( + Object.assign(value as NonNullable, { + toJSON: () => "custom", + }), + ), + true, + ); + }, + ); + } + } + + for (const [name, value] of testcases) { + switch (name) { + case "bigint": + case "function": + case "symbol": + await t.step( + `return true for ${name} if it class defines custom toJSON method`, + () => { + // deno-lint-ignore no-explicit-any + (value as any).constructor.prototype.toJSON = () => "custom"; + try { + assertEquals(isJsonable(value), true); + } finally { + // deno-lint-ignore no-explicit-any + delete (value as any).constructor.prototype.toJSON; + } + }, + ); + } + } + + await t.step( + "returns true on circular reference (unwilling behavior)", + () => { + const circular = { a: {} }; + circular["a"] = circular; + assertEquals(isJsonable(circular), true); + }, + ); +}); diff --git a/is/mod.ts b/is/mod.ts index 6e6231c..da2ece6 100644 --- a/is/mod.ts +++ b/is/mod.ts @@ -5,9 +5,11 @@ 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"; +import { isJsonable } from "./jsonable.ts"; import { isLiteralOf } from "./literal_of.ts"; import { isLiteralOneOf } from "./literal_one_of.ts"; import { isMap } from "./map.ts"; @@ -45,9 +47,11 @@ 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"; +export * from "./jsonable.ts"; export * from "./literal_of.ts"; export * from "./literal_one_of.ts"; export * from "./map.ts"; @@ -173,6 +177,13 @@ export const is: { * ``` */ Boolean: typeof isBoolean; + /** + * Returns true if `x` has own custom `toJSON` method ({@linkcode CustomJsonable}), false otherwise. + * + * Use {@linkcode [is/jsonable].isJsonable|isJsonable} to check if the type of `x` is a JSON-serializable. + * + */ + CustomJsonable: typeof isCustomJsonable; /** * Return `true` if the type of `x` is `function`. * @@ -243,6 +254,13 @@ export const is: { * ``` */ IntersectionOf: typeof isIntersectionOf; + /** + * Returns true if `x` is a JSON-serializable value, false otherwise. + * + * Use {@linkcode [is/custom_jsonable].isCustomJsonable|isCustomJsonable} to check if the type of `x` has a custom `toJSON` method. + * + */ + Jsonable: typeof isJsonable; /** * Return a type predicate function that returns `true` if the type of `x` is a literal type of `pred`. * @@ -1005,9 +1023,11 @@ export const is: { AsyncFunction: isAsyncFunction, Bigint: isBigint, Boolean: isBoolean, + CustomJsonable: isCustomJsonable, Function: isFunction, InstanceOf: isInstanceOf, IntersectionOf: isIntersectionOf, + Jsonable: isJsonable, LiteralOf: isLiteralOf, LiteralOneOf: isLiteralOneOf, Map: isMap,