Skip to content

Commit

Permalink
feat: add isCustomJsonable and isJsonable
Browse files Browse the repository at this point in the history
  • Loading branch information
lambdalisue committed Aug 21, 2024
1 parent d2075df commit 142f99b
Show file tree
Hide file tree
Showing 8 changed files with 459 additions and 0 deletions.
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
30 changes: 30 additions & 0 deletions is/custom_jsonable.ts
Original file line number Diff line number Diff line change
@@ -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";
}
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)",
});
99 changes: 99 additions & 0 deletions is/custom_jsonable_test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>, { 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<unknown>, {
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;
}
},
);
}
});
55 changes: 55 additions & 0 deletions is/jsonable.ts
Original file line number Diff line number Diff line change
@@ -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}`);
}
}
94 changes: 94 additions & 0 deletions is/jsonable_bench.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>, {
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)`,
});
}
}
Loading

0 comments on commit 142f99b

Please sign in to comment.