From 1ef2706b100c3a826fbe064bfc6aec75b956b960 Mon Sep 17 00:00:00 2001 From: Alisue Date: Fri, 23 Aug 2024 20:05:10 +0900 Subject: [PATCH 1/2] feat(isCustomJsonable): add `isCustomJsonable` function --- deno.jsonc | 1 + is/custom_jsonable.ts | 40 +++++++++++++ is/custom_jsonable_bench.ts | 22 +++++++ is/custom_jsonable_test.ts | 114 ++++++++++++++++++++++++++++++++++++ is/mod.ts | 22 +++++++ 5 files changed, 199 insertions(+) create mode 100644 is/custom_jsonable.ts create mode 100644 is/custom_jsonable_bench.ts create mode 100644 is/custom_jsonable_test.ts diff --git a/deno.jsonc b/deno.jsonc index c6764fb..42554a7 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -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", diff --git a/is/custom_jsonable.ts b/is/custom_jsonable.ts new file mode 100644 index 0000000..d4fc18c --- /dev/null +++ b/is/custom_jsonable.ts @@ -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; +} 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..52b1495 --- /dev/null +++ b/is/custom_jsonable_test.ts @@ -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, { 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, { + 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; + } + }, + ); + } + } +}); diff --git a/is/mod.ts b/is/mod.ts index 6e6231c..62e6b2c 100644 --- a/is/mod.ts +++ b/is/mod.ts @@ -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"; @@ -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"; @@ -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`. * @@ -1005,6 +1026,7 @@ export const is: { AsyncFunction: isAsyncFunction, Bigint: isBigint, Boolean: isBoolean, + CustomJsonable: isCustomJsonable, Function: isFunction, InstanceOf: isInstanceOf, IntersectionOf: isIntersectionOf, From 39e4eb1401dda9d8ae3f6eced4710df1ec30cbb1 Mon Sep 17 00:00:00 2001 From: Alisue Date: Fri, 23 Aug 2024 20:09:50 +0900 Subject: [PATCH 2/2] feat(isJsonable): add `isJsonable` function --- deno.jsonc | 1 + is/jsonable.ts | 54 +++++++++++++++++ is/jsonable_bench.ts | 71 ++++++++++++++++++++++ is/jsonable_test.ts | 136 +++++++++++++++++++++++++++++++++++++++++++ is/mod.ts | 20 +++++++ 5 files changed, 282 insertions(+) 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/jsonable.ts b/is/jsonable.ts new file mode 100644 index 0000000..5c36d21 --- /dev/null +++ b/is/jsonable.ts @@ -0,0 +1,54 @@ +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": + case "symbol": + case "function": + 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; + } + } +} diff --git a/is/jsonable_bench.ts b/is/jsonable_bench.ts new file mode 100644 index 0000000..42edd30 --- /dev/null +++ b/is/jsonable_bench.ts @@ -0,0 +1,71 @@ +import { assert } from "@std/assert"; +import { isJsonable } from "./jsonable.ts"; +import { buildTestcases } from "./custom_jsonable_test.ts"; + +const repeats = Array.from({ length: 100 }); + +for (const [name, value] of buildTestcases()) { + 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 buildTestcases()) { + 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 buildTestcases()) { + 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..6f4fb7b --- /dev/null +++ b/is/jsonable_test.ts @@ -0,0 +1,136 @@ +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. + 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 "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; + case "bigint": + case "function": + // toJSON method defined in the class prototype makes bigint, function Jsonable. + 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 62e6b2c..59c77da 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,23 @@ export const is: { * ``` */ IntersectionOf: typeof isIntersectionOf; + /** + * 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; + * } + * ``` + */ + Jsonable: typeof isJsonable; /** * Return a type predicate function that returns `true` if the type of `x` is a literal type of `pred`. * @@ -1030,6 +1049,7 @@ export const is: { Function: isFunction, InstanceOf: isInstanceOf, IntersectionOf: isIntersectionOf, + Jsonable: isJsonable, LiteralOf: isLiteralOf, LiteralOneOf: isLiteralOneOf, Map: isMap,