From 3844263a22fd64a30dd198312a4888e9c83c11e6 Mon Sep 17 00:00:00 2001 From: Alisue Date: Fri, 23 Aug 2024 04:50:23 +0900 Subject: [PATCH] feat(isJsonable): add isJsonable function --- deno.jsonc | 1 + is/custom_jsonable_test.ts | 2 +- is/jsonable.ts | 55 +++++++++++++ is/jsonable_bench.ts | 94 +++++++++++++++++++++ is/jsonable_test.ts | 163 +++++++++++++++++++++++++++++++++++++ is/mod.ts | 18 ++++ 6 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 is/jsonable.ts create mode 100644 is/jsonable_bench.ts create mode 100644 is/jsonable_test.ts diff --git a/deno.jsonc b/deno.jsonc index 42554a7..7d37baf 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -19,6 +19,7 @@ "./is/function": "./is/function.ts", "./is/instance-of": "./is/instance_of.ts", "./is/intersection-of": "./is/intersection_of.ts", + "./is/jsonable": "./is/jsonable.ts", "./is/literal-of": "./is/literal_of.ts", "./is/literal-one-of": "./is/literal_one_of.ts", "./is/map": "./is/map.ts", diff --git a/is/custom_jsonable_test.ts b/is/custom_jsonable_test.ts index d3024bd..1d255e8 100644 --- a/is/custom_jsonable_test.ts +++ b/is/custom_jsonable_test.ts @@ -1,7 +1,7 @@ import { assertEquals } from "@std/assert"; import { isCustomJsonable } from "./custom_jsonable.ts"; -function buildTestcases(): readonly [name: string, value: unknown][] { +export function buildTestcases(): readonly [name: string, value: unknown][] { return [ ["undefined", undefined], ["null", null], diff --git a/is/jsonable.ts b/is/jsonable.ts new file mode 100644 index 0000000..ff6a3de --- /dev/null +++ b/is/jsonable.ts @@ -0,0 +1,55 @@ +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 + | unknown[] + | { [key: string]: unknown } + | CustomJsonable; + +/** + * Returns true if `x` is a JSON-serializable value, false otherwise. + * + * It does not check array or object properties recursively. + * + * Use {@linkcode [is/custom_jsonable].isCustomJsonable|isCustomJsonable} to check if the type of `x` has a custom `toJSON` method. + * + * ```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 || Array.isArray(x)) return true; + const p = Object.getPrototypeOf(x); + if (p === BigInt.prototype || p === Function.prototype) { + return isCustomJsonable(x); + } + return true; + } + case "symbol": + case "function": + return isCustomJsonable(x); + } +} diff --git a/is/jsonable_bench.ts b/is/jsonable_bench.ts new file mode 100644 index 0000000..bc14070 --- /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 "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 "undefined": + case "null": + continue; + case "bigint": + case "function": + Deno.bench({ + name: "current", + fn() { + const v = Object.assign(value as NonNullable, { + toJSON: () => "custom", + }); + assert(repeats.every(() => isJsonable(v))); + }, + group: `isJsonable (${name} with own toJSON method)`, + }); + } +} + +for (const [name, value] of testcases) { + switch (name) { + case "bigint": + case "function": + Deno.bench({ + name: "current", + fn() { + const proto = Object.getPrototypeOf(value); + proto.toJSON = () => "custom"; + try { + assert(repeats.every(() => isJsonable(value))); + } finally { + delete proto.toJSON; + } + }, + group: + `isJsonable (${name} with class prototype defines toJSON method)`, + }); + } +} diff --git a/is/jsonable_test.ts b/is/jsonable_test.ts new file mode 100644 index 0000000..9670c28 --- /dev/null +++ b/is/jsonable_test.ts @@ -0,0 +1,163 @@ +import { assertEquals } from "@std/assert"; +import { isJsonable } from "./jsonable.ts"; +import { buildTestcases } from "./custom_jsonable_test.ts"; + +Deno.test("isJsonable", async (t) => { + for (const [name, value] of buildTestcases()) { + switch (name) { + case "undefined": + 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 buildTestcases()) { + switch (name) { + case "undefined": + case "null": + // Skip undefined, null that is not supported by Object.assign. + continue; + case "bigint": + case "function": + // Object.assign() doesn't make bigint, function Jsonable. + await t.step( + `return false for ${name} even if it is wrapped by Object.assign()`, + () => { + assertEquals( + isJsonable( + Object.assign(value as NonNullable, { a: 0 }), + ), + false, + ); + }, + ); + break; + default: + // Object.assign() makes other values Jsonable. + await t.step( + `return true for ${name} if it is wrapped by Object.assign()`, + () => { + assertEquals( + isJsonable( + Object.assign(value as NonNullable, { a: 0 }), + ), + true, + ); + }, + ); + } + } + + for (const [name, value] of buildTestcases()) { + switch (name) { + case "undefined": + case "null": + // Skip undefined, null that is not supported by Object.assign. + continue; + case "bigint": + case "function": + // toJSON method assigned with Object.assign() makes bigint, function Jsonable. + await t.step( + `return true for ${name} if it has own toJSON method`, + () => { + assertEquals( + isJsonable( + Object.assign(value as NonNullable, { + toJSON: () => "custom", + }), + ), + true, + ); + }, + ); + break; + default: + // toJSON method assigned with Object.assign() makes other values Jsonable. + 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 buildTestcases()) { + switch (name) { + case "undefined": + case "null": + // Skip undefined, null that does not have prototype + continue; + case "bigint": + case "function": + // toJSON method defined in the class prototype makes bigint, function Jsonable. + await t.step( + `return true for ${name} if the class prototype defines toJSON method`, + () => { + const proto = Object.getPrototypeOf(value); + proto.toJSON = () => "custom"; + try { + assertEquals(isJsonable(value), true); + } finally { + delete proto.toJSON; + } + }, + ); + break; + case "symbol": + // toJSON method defined in the class prototype does not make symbol Jsonable. + await t.step( + `return false for ${name} if the class prototype defines toJSON method`, + () => { + const proto = Object.getPrototypeOf(value); + proto.toJSON = () => "custom"; + try { + assertEquals(isJsonable(value), false); + } finally { + delete proto.toJSON; + } + }, + ); + break; + default: + // toJSON method defined in the class prototype makes other values Jsonable. + await t.step( + `return true for ${name} if the class prototype defines toJSON method`, + () => { + const proto = Object.getPrototypeOf(value); + proto.toJSON = () => "custom"; + try { + assertEquals(isJsonable(value), true); + } finally { + delete proto.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 280f889..aea69a8 100644 --- a/is/mod.ts +++ b/is/mod.ts @@ -9,6 +9,7 @@ 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"; @@ -50,6 +51,7 @@ 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"; @@ -264,6 +266,21 @@ 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. + * + * ```ts + * import { is, Jsonable } from "@core/unknownutil"; + * + * const a: unknown = "Hello, world!"; + * if (is.Jsonable(a)) { + * const _: Jsonable = a; + * } + * ``` + */ + Jsonable: typeof isJsonable; /** * Return a type predicate function that returns `true` if the type of `x` is a literal type of `pred`. * @@ -1030,6 +1047,7 @@ export const is: { Function: isFunction, InstanceOf: isInstanceOf, IntersectionOf: isIntersectionOf, + Jsonable: isJsonable, LiteralOf: isLiteralOf, LiteralOneOf: isLiteralOneOf, Map: isMap,