Skip to content

Commit

Permalink
Merge pull request #128 from jsr-core/add-is-jsonable
Browse files Browse the repository at this point in the history
feat: add `isCustomJsonable` and `isJsonable`
  • Loading branch information
lambdalisue authored Aug 24, 2024
2 parents d2075df + 39e4eb1 commit 6f1acaa
Show file tree
Hide file tree
Showing 8 changed files with 481 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
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.
*
* 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)`,
});
}
}

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

0 comments on commit 6f1acaa

Please sign in to comment.