diff --git a/deno.jsonc b/deno.jsonc index c6764fb..7d37baf 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -15,9 +15,11 @@ "./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", + "./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.ts b/is/custom_jsonable.ts new file mode 100644 index 0000000..f664fd2 --- /dev/null +++ b/is/custom_jsonable.ts @@ -0,0 +1,30 @@ +/** + * 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. + * + * ```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..fabc6d6 --- /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 + | 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. + * + * ```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..aea69a8 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,25 @@ 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. + * + * ```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`. * @@ -243,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`. * @@ -1005,9 +1043,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,