-
-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #128 from jsr-core/add-is-jsonable
feat: add `isCustomJsonable` and `isJsonable`
- Loading branch information
Showing
8 changed files
with
481 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)", | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
}, | ||
); | ||
} | ||
} | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)`, | ||
}); | ||
} | ||
} |
Oops, something went wrong.