Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add isCustomJsonable and isJsonable #128

Merged
merged 2 commits into from
Aug 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions deno.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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",
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;
}
},
);
}
}
});
54 changes: 54 additions & 0 deletions is/jsonable.ts
Original file line number Diff line number Diff line change
@@ -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.
*
lambdalisue marked this conversation as resolved.
Show resolved Hide resolved
* 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;
}
}
}
71 changes: 71 additions & 0 deletions is/jsonable_bench.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>, {
toJSON: () => "custom",
});
assert(repeats.every(() => isJsonable(v)));
},
group: `isJsonable (${name} with own toJSON method)`,
});
}
lambdalisue marked this conversation as resolved.
Show resolved Hide resolved
}

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)`,
});
}
}
Loading