diff --git a/package.json b/package.json index 2c4d8efe..2420e832 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ ], "scripts": { "build": "tsup", + "fix": "pnpm lint --fix && pnpm format:write", "format": "prettier \"**/*\" --ignore-unknown", "format:write": "pnpm format --write", "lint": "eslint . .*js --max-warnings 0 --report-unused-disable-directives", @@ -32,6 +33,7 @@ }, "devDependencies": { "@release-it/conventional-changelog": "^7.0.2", + "@tsconfig/strictest": "^2.0.2", "@types/eslint": "^8.44.3", "@typescript-eslint/eslint-plugin": "^6.7.3", "@typescript-eslint/parser": "^6.7.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae13b783..826d7558 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ devDependencies: '@release-it/conventional-changelog': specifier: ^7.0.2 version: 7.0.2(release-it@16.2.1) + '@tsconfig/strictest': + specifier: ^2.0.2 + version: 2.0.2 '@types/eslint': specifier: ^8.44.3 version: 8.44.3 @@ -1124,6 +1127,10 @@ packages: resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} dev: true + /@tsconfig/strictest@2.0.2: + resolution: {integrity: sha512-jt4jIsWKvUvuY6adJnQJlb/UR7DdjC8CjHI/OaSQruj2yX9/K6+KOvDt/vD6udqos/FUk5Op66CvYT7TBLYO5Q==} + dev: true + /@types/chai-subset@1.3.3: resolution: {integrity: sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw==} dependencies: diff --git a/src/handlers.ts b/src/handlers.ts deleted file mode 100644 index c28832f6..00000000 --- a/src/handlers.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { TsonType } from "./types.js"; -import { isPlainObject } from "./utils.js"; - -export const tsonMap: TsonType, [unknown, unknown][]> = { - deserialize: (v) => new Map(v), - serialize: (v) => Array.from(v.entries()), - test: (v) => v instanceof Map, -}; - -export const tsonSet: TsonType, unknown[]> = { - deserialize: (v) => new Set(v), - serialize: (v) => Array.from(v), - test: (v) => v instanceof Set, -}; - -export const tsonBigint: TsonType = { - deserialize: (v) => BigInt(v), - primitive: "bigint", - serialize: (v) => v.toString(), -}; - -/** - * Prevents `NaN` and `Infinity` from being serialized - */ -export const tsonNumber: TsonType = { - primitive: "number", - test: (v) => { - const value = v as number; - if (isNaN(value)) { - throw new Error("Encountered NaN"); - } - - if (!isFinite(value)) { - throw new Error("Encountered Infinity"); - } - - return false; - }, - transform: false, -}; - -export const tsonUndefined: TsonType = { - deserialize: () => undefined, - primitive: "undefined", - serialize: () => 0, -}; - -export const tsonDate: TsonType = { - deserialize: (value) => new Date(value), - serialize: (value) => value.toJSON(), - test: (value) => value instanceof Date, -}; - -export class UnknownObjectGuardError extends Error { - public readonly value; - - constructor(value: unknown) { - super(`Unknown object found`); - this.name = this.constructor.name; - this.value = value; - } -} - -export const tsonUnknown: TsonType = { - test: (v) => { - if (v && typeof v === "object" && !Array.isArray(v) && !isPlainObject(v)) { - throw new UnknownObjectGuardError(v); - } - - return false; - }, - transform: false, -}; - -export const tsonRegExp: TsonType = { - deserialize: (str) => { - const body = str.slice(1, str.lastIndexOf("/")); - const flags = str.slice(str.lastIndexOf("/") + 1); - return new RegExp(body, flags); - }, - serialize: (value) => "" + value, - test: (value) => value instanceof RegExp, -}; diff --git a/src/handlers/index.ts b/src/handlers/index.ts new file mode 100644 index 00000000..f2966607 --- /dev/null +++ b/src/handlers/index.ts @@ -0,0 +1,8 @@ +export * from "./tsonBigint.js"; +export * from "./tsonDate.js"; +export * from "./tsonRegExp.js"; +export * from "./tsonSet.js"; +export * from "./tsonMap.js"; +export * from "./tsonUndefined.js"; +export * from "./tsonUnknownObjectGuard.js"; +export * from "./tsonNumber.js"; diff --git a/src/handlers/tsonBigint.test.ts b/src/handlers/tsonBigint.test.ts new file mode 100644 index 00000000..50533235 --- /dev/null +++ b/src/handlers/tsonBigint.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from "vitest"; + +import { createTupleson } from "../tson.js"; +import { tsonBigint } from "./tsonBigint.js"; +import { tsonMap } from "./tsonMap.js"; +import { tsonSet } from "./tsonSet.js"; + +test("bigint", () => { + const t = createTupleson({ + types: [tsonMap, tsonSet, tsonBigint], + }); + + { + // bigint + const expected = 1n; + + const stringified = t.stringify(expected); + const deserialized = t.parse(stringified); + + expect(deserialized).toEqual(expected); + + { + // set of BigInt + const expected = new Set([1n]); + + const stringified = t.stringify(expected); + const deserialized = t.parse(stringified); + + expect(deserialized).toEqual(expected); + } + + { + // set of a map of bigint + const expected = new Set([new Map([["a", 1n]])]); + + const stringified = t.stringify(expected); + const deserialized = t.parse(stringified); + + expect(deserialized).toEqual(expected); + } + } +}); diff --git a/src/handlers/tsonBigint.ts b/src/handlers/tsonBigint.ts new file mode 100644 index 00000000..2d0d0523 --- /dev/null +++ b/src/handlers/tsonBigint.ts @@ -0,0 +1,8 @@ +import { TsonType } from "../types.js"; + +export const tsonBigint: TsonType = { + deserialize: (v) => BigInt(v), + key: "bigint", + primitive: "bigint", + serialize: (v) => v.toString(), +}; diff --git a/src/handlers/tsonDate.test.ts b/src/handlers/tsonDate.test.ts new file mode 100644 index 00000000..7b549d12 --- /dev/null +++ b/src/handlers/tsonDate.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from "vitest"; + +import { createTupleson } from "../tson.js"; +import { tsonDate } from "./tsonDate.js"; + +test("Date", () => { + const ctx = createTupleson({ + types: [tsonDate], + }); + + const date = new Date(); + + const stringified = ctx.stringify(date); + const deserialized = ctx.parse(stringified); + expect(deserialized).toEqual(date); +}); diff --git a/src/handlers/tsonDate.ts b/src/handlers/tsonDate.ts new file mode 100644 index 00000000..36ab43e8 --- /dev/null +++ b/src/handlers/tsonDate.ts @@ -0,0 +1,8 @@ +import { TsonType } from "../types.js"; + +export const tsonDate: TsonType = { + deserialize: (value) => new Date(value), + key: "Date", + serialize: (value) => value.toJSON(), + test: (value) => value instanceof Date, +}; diff --git a/src/handlers/tsonMap.test.ts b/src/handlers/tsonMap.test.ts new file mode 100644 index 00000000..71e73e50 --- /dev/null +++ b/src/handlers/tsonMap.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from "vitest"; + +import { createTupleson } from "../tson.js"; +import { tsonMap } from "./tsonMap.js"; + +test("Map", () => { + const t = createTupleson({ + types: [tsonMap], + }); + + const expected = new Map([["a", "b"]]); + + const stringified = t.stringify(expected); + const deserialized = t.parse(stringified); + expect(deserialized).toEqual(expected); +}); diff --git a/src/handlers/tsonMap.ts b/src/handlers/tsonMap.ts new file mode 100644 index 00000000..11266a53 --- /dev/null +++ b/src/handlers/tsonMap.ts @@ -0,0 +1,8 @@ +import { TsonType } from "../types.js"; + +export const tsonMap: TsonType, [unknown, unknown][]> = { + deserialize: (v) => new Map(v), + key: "Map", + serialize: (v) => Array.from(v.entries()), + test: (v) => v instanceof Map, +}; diff --git a/src/handlers/tsonNumber.test.ts b/src/handlers/tsonNumber.test.ts new file mode 100644 index 00000000..3e3acc91 --- /dev/null +++ b/src/handlers/tsonNumber.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from "vitest"; + +import { expectError } from "../testUtils.js"; +import { createTupleson } from "../tson.js"; +import { tsonNumber } from "./tsonNumber.js"; + +test("number", () => { + const t = createTupleson({ + types: [tsonNumber], + }); + + const bad = [ + // + NaN, + Infinity, + -Infinity, + ]; + const good = [1, 0, -1, 1.1, -1.1]; + + const errors: unknown[] = []; + + for (const n of bad) { + const err = expectError(() => t.parse(t.stringify(n))); + errors.push(err); + } + + expect(errors).toMatchInlineSnapshot(` + [ + [Error: Encountered NaN], + [Error: Encountered Infinity], + [Error: Encountered Infinity], + ] + `); + + for (const n of good) { + const deserialized = t.parse(t.stringify(n)); + expect(deserialized).toEqual(n); + } +}); diff --git a/src/handlers/tsonNumber.ts b/src/handlers/tsonNumber.ts new file mode 100644 index 00000000..70677b99 --- /dev/null +++ b/src/handlers/tsonNumber.ts @@ -0,0 +1,21 @@ +import { TsonType } from "../types.js"; + +/** + * Prevents `NaN` and `Infinity` from being serialized + */ + +export const tsonNumber: TsonType = { + primitive: "number", + test: (v) => { + const value = v as number; + if (isNaN(value)) { + throw new Error("Encountered NaN"); + } + + if (!isFinite(value)) { + throw new Error("Encountered Infinity"); + } + + return false; + }, +}; diff --git a/src/handlers/tsonRegExp.test.ts b/src/handlers/tsonRegExp.test.ts new file mode 100644 index 00000000..f22df134 --- /dev/null +++ b/src/handlers/tsonRegExp.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from "vitest"; + +import { createTupleson } from "../tson.js"; +import { tsonRegExp } from "./index.js"; + +test("regex", () => { + const t = createTupleson({ + types: [tsonRegExp], + }); + + const expected = /foo/g; + + const stringified = t.stringify(expected, 2); + + expect(stringified).toMatchInlineSnapshot( + ` + "{ + \\"json\\": [ + \\"RegExp\\", + \\"/foo/g\\", + \\"__tson\\" + ], + \\"nonce\\": \\"__tson\\" + }" + `, + ); + + const deserialized = t.parse(stringified); + + expect(deserialized).toBeInstanceOf(RegExp); + expect(deserialized).toMatchInlineSnapshot("/foo/g"); + expect(deserialized + "").toEqual(expected + ""); +}); diff --git a/src/handlers/tsonRegExp.ts b/src/handlers/tsonRegExp.ts new file mode 100644 index 00000000..eab9c544 --- /dev/null +++ b/src/handlers/tsonRegExp.ts @@ -0,0 +1,12 @@ +import { TsonType } from "../types.js"; + +export const tsonRegExp: TsonType = { + deserialize: (str) => { + const body = str.slice(1, str.lastIndexOf("/")); + const flags = str.slice(str.lastIndexOf("/") + 1); + return new RegExp(body, flags); + }, + key: "RegExp", + serialize: (value) => "" + value, + test: (value) => value instanceof RegExp, +}; diff --git a/src/handlers/tsonSet.test.ts b/src/handlers/tsonSet.test.ts new file mode 100644 index 00000000..00c1ad58 --- /dev/null +++ b/src/handlers/tsonSet.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from "vitest"; + +import { createTupleson } from "../tson.js"; +import { tsonSet } from "./tsonSet.js"; + +test("Set", () => { + const t = createTupleson({ + types: [tsonSet], + }); + + const expected = new Set(["a", "b"]); + + const stringified = t.stringify(expected); + const deserialized = t.parse(stringified); + expect(deserialized).toEqual(expected); +}); diff --git a/src/handlers/tsonSet.ts b/src/handlers/tsonSet.ts new file mode 100644 index 00000000..4b8fe6fe --- /dev/null +++ b/src/handlers/tsonSet.ts @@ -0,0 +1,8 @@ +import { TsonType } from "../types.js"; + +export const tsonSet: TsonType, unknown[]> = { + deserialize: (v) => new Set(v), + key: "Set", + serialize: (v) => Array.from(v), + test: (v) => v instanceof Set, +}; diff --git a/src/handlers/tsonURL.test.ts b/src/handlers/tsonURL.test.ts new file mode 100644 index 00000000..ba70be89 --- /dev/null +++ b/src/handlers/tsonURL.test.ts @@ -0,0 +1,31 @@ +import { expect, test } from "vitest"; + +import { createTupleson } from "../tson.js"; +import { tsonURL } from "./tsonURL.js"; + +test("URL", () => { + const ctx = createTupleson({ + types: [tsonURL], + }); + + const expected = new URL("https://trpc.io/"); + expected.hash = "foo"; + expected.pathname = "sponsor"; + + const stringified = ctx.stringify(expected, 2); + + expect(stringified).toMatchInlineSnapshot( + ` + "{ + \\"json\\": [ + \\"URL\\", + \\"https://trpc.io/sponsor#foo\\", + \\"__tson\\" + ], + \\"nonce\\": \\"__tson\\" + }" + `, + ); + const deserialized = ctx.parse(stringified); + expect(deserialized).toEqual(expected); +}); diff --git a/src/handlers/tsonURL.ts b/src/handlers/tsonURL.ts new file mode 100644 index 00000000..33a05ae9 --- /dev/null +++ b/src/handlers/tsonURL.ts @@ -0,0 +1,8 @@ +import { TsonType } from "../types.js"; + +export const tsonURL: TsonType = { + deserialize: (value) => new URL(value), + key: "URL", + serialize: (value) => value.toString(), + test: (value) => value instanceof URL, +}; diff --git a/src/handlers/tsonUndefined.test.ts b/src/handlers/tsonUndefined.test.ts new file mode 100644 index 00000000..21cfaf0a --- /dev/null +++ b/src/handlers/tsonUndefined.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from "vitest"; + +import { createTupleson } from "../tson.js"; +import { tsonUndefined } from "./tsonUndefined.js"; + +test("undefined", () => { + const ctx = createTupleson({ + types: [tsonUndefined], + }); + + const expected = { + foo: [1, undefined, 2], + } as const; + const stringified = ctx.stringify(expected); + const deserialized = ctx.parse(stringified); + + expect(deserialized).toEqual(expected); +}); diff --git a/src/handlers/tsonUndefined.ts b/src/handlers/tsonUndefined.ts new file mode 100644 index 00000000..4d638e5f --- /dev/null +++ b/src/handlers/tsonUndefined.ts @@ -0,0 +1,8 @@ +import { TsonType } from "../types.js"; + +export const tsonUndefined: TsonType = { + deserialize: () => undefined, + key: "undefined", + primitive: "undefined", + serialize: () => 0, +}; diff --git a/src/handlers/tsonUnknownObjectGuard.test.ts b/src/handlers/tsonUnknownObjectGuard.test.ts new file mode 100644 index 00000000..36270841 --- /dev/null +++ b/src/handlers/tsonUnknownObjectGuard.test.ts @@ -0,0 +1,51 @@ +import { assert, expect, test } from "vitest"; + +import { expectError } from "../testUtils.js"; +import { createTupleson } from "../tson.js"; +import { tsonSet } from "./tsonSet.js"; +import { + UnknownObjectGuardError, + tsonUnknownObjectGuard, +} from "./tsonUnknownObjectGuard.js"; + +test("guard unwanted objects", () => { + // Sets are okay, but not Maps + const t = createTupleson({ + types: [ + tsonSet, + // defined last so it runs last + tsonUnknownObjectGuard, + ], + }); + + { + // sets are okay + const expected = new Set([1]); + + const stringified = t.stringify(expected); + const deserialized = t.parse(stringified); + + expect(deserialized).toEqual(expected); + } + + { + // plain objects are okay + const expected = { a: 1 }; + const stringified = t.stringify(expected); + const deserialized = t.parse(stringified); + expect(deserialized).toEqual(expected); + } + + { + // maps are not okay + const expected = new Map([["a", 1]]); + + const err = expectError(() => t.parse(t.stringify(expected))); + assert(err instanceof UnknownObjectGuardError); + + expect(err).toMatchInlineSnapshot( + "[UnknownObjectGuardError: Unknown object found]", + ); + expect(err.value).toEqual(expected); + } +}); diff --git a/src/handlers/tsonUnknownObjectGuard.ts b/src/handlers/tsonUnknownObjectGuard.ts new file mode 100644 index 00000000..eb5e7956 --- /dev/null +++ b/src/handlers/tsonUnknownObjectGuard.ts @@ -0,0 +1,32 @@ +import { TsonType } from "../types.js"; +import { isPlainObject } from "../utils.js"; + +export class UnknownObjectGuardError extends Error { + /** + * The unknown object that was found + */ + public readonly value; + + constructor(value: unknown) { + super(`Unknown object found`); + this.name = this.constructor.name; + this.value = value; + } +} + +/** + * + * @description + * Guard against unknown complex objects. + * Make sure to define this last in the list of types. + * @throws {UnknownObjectGuardError} if an unknown object is found + */ +export const tsonUnknownObjectGuard: TsonType = { + test: (v) => { + if (v && typeof v === "object" && !Array.isArray(v) && !isPlainObject(v)) { + throw new UnknownObjectGuardError(v); + } + + return false; + }, +}; diff --git a/src/index.ts b/src/index.ts index 498706e4..21fc3845 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export { + createTupleson, tsonDeserializer, tsonParser, tsonSerializer, diff --git a/src/stringify.test.ts b/src/stringify.test.ts new file mode 100644 index 00000000..65311b52 --- /dev/null +++ b/src/stringify.test.ts @@ -0,0 +1,68 @@ +import { expect, test } from "vitest"; + +import { tsonBigint } from "./handlers/tsonBigint.js"; +import { tsonMap } from "./handlers/tsonMap.js"; +import { tsonSet } from "./handlers/tsonSet.js"; +import { tsonUndefined } from "./handlers/tsonUndefined.js"; +import { createTupleson } from "./tson.js"; + +test("lets have a look at the stringified output", () => { + const t = createTupleson({ + types: [tsonMap, tsonSet, tsonBigint, tsonUndefined], + }); + + const expected = new Set([ + // + 1, + "string", + undefined, + null, + true, + false, + 1n, + new Map([["foo", "bar"]]), + ]); + + const stringified = t.stringify(expected, 2); + + expect(stringified).toMatchInlineSnapshot(` + "{ + \\"json\\": [ + \\"Set\\", + [ + 1, + \\"string\\", + [ + \\"undefined\\", + 0, + \\"__tson\\" + ], + null, + true, + false, + [ + \\"bigint\\", + \\"1\\", + \\"__tson\\" + ], + [ + \\"Map\\", + [ + [ + \\"foo\\", + \\"bar\\" + ] + ], + \\"__tson\\" + ] + ], + \\"__tson\\" + ], + \\"nonce\\": \\"__tson\\" + }" + `); + + const deserialized = t.parse(stringified); + + expect(deserialized).toEqual(expected); +}); diff --git a/src/testUtils.ts b/src/testUtils.ts new file mode 100644 index 00000000..bf5f02d8 --- /dev/null +++ b/src/testUtils.ts @@ -0,0 +1,14 @@ +import { expect } from "vitest"; + +export const expectError = (fn: () => unknown) => { + let err: unknown; + try { + fn(); + } catch (_err) { + err = _err; + } + + expect(err).toBeDefined(); + expect(err).toBeInstanceOf(Error); + return err as Error; +}; diff --git a/src/tson.test.ts b/src/tson.test.ts deleted file mode 100644 index f68e5c96..00000000 --- a/src/tson.test.ts +++ /dev/null @@ -1,349 +0,0 @@ -import { assert, expect, expectTypeOf, test } from "vitest"; - -import { - UnknownObjectGuardError, - tsonBigint, - tsonDate, - tsonMap, - tsonNumber, - tsonRegExp, - tsonSet, - tsonUndefined, - tsonUnknown, -} from "./handlers.js"; -import { - tsonDeserializer, - tsonParser, - tsonSerializer, - tsonStringifier, -} from "./tson.js"; -import { TsonOptions } from "./types.js"; - -const expectError = (fn: () => unknown) => { - let err: unknown; - try { - fn(); - } catch (_err) { - err = _err; - } - - expect(err).toBeDefined(); - expect(err).toBeInstanceOf(Error); - return err as Error; -}; - -function setup(opts: TsonOptions) { - const nonce: TsonOptions["nonce"] = () => "__tson"; - const withDefaults: TsonOptions = { - nonce, - ...opts, - }; - return { - deserialize: tsonDeserializer(withDefaults), - parse: tsonParser(withDefaults), - serializer: tsonSerializer(withDefaults), - stringify: tsonStringifier(withDefaults), - }; -} - -test("Date", () => { - const ctx = setup({ - types: { - Date: tsonDate, - }, - }); - - const date = new Date(); - - const stringified = ctx.stringify(date); - const deserialized = ctx.parse(stringified); - expect(deserialized).toEqual(date); -}); - -test("number", () => { - const t = setup({ - types: { - number: tsonNumber, - }, - }); - - const bad = [ - // - NaN, - Infinity, - -Infinity, - ]; - const good = [1, 0, -1, 1.1, -1.1]; - - const errors: unknown[] = []; - - for (const n of bad) { - const err = expectError(() => t.parse(t.stringify(n))); - errors.push(err); - } - - expect(errors).toMatchInlineSnapshot(` - [ - [Error: Encountered NaN], - [Error: Encountered Infinity], - [Error: Encountered Infinity], - ] - `); - - for (const n of good) { - const deserialized = t.parse(t.stringify(n)); - expect(deserialized).toEqual(n); - } -}); - -test("undefined", () => { - const ctx = setup({ - types: { - undefined: tsonUndefined, - }, - }); - - const expected = { - foo: [1, undefined, 2], - } as const; - const stringified = ctx.stringify(expected); - const deserialized = ctx.parse(stringified); - - expect(deserialized).toEqual(expected); -}); - -test("Map", () => { - const t = setup({ - types: { - Map: tsonMap, - }, - }); - - const expected = new Map([["a", "b"]]); - - const stringified = t.stringify(expected); - const deserialized = t.parse(stringified); - expect(deserialized).toEqual(expected); -}); - -test("Set", () => { - const t = setup({ - types: { - Set: tsonSet, - }, - }); - - const expected = new Set(["a", "b"]); - - const stringified = t.stringify(expected); - const deserialized = t.parse(stringified); - expect(deserialized).toEqual(expected); -}); - -test("bigint", () => { - const t = setup({ - types: { - Map: tsonMap, - Set: tsonSet, - bigint: tsonBigint, - }, - }); - - { - // bigint - const expected = 1n; - - const stringified = t.stringify(expected); - const deserialized = t.parse(stringified); - - expect(deserialized).toEqual(expected); - - { - // set of BigInt - const expected = new Set([1n]); - - const stringified = t.stringify(expected); - const deserialized = t.parse(stringified); - - expect(deserialized).toEqual(expected); - } - - { - // set of a map of bigint - const expected = new Set([new Map([["a", 1n]])]); - - const stringified = t.stringify(expected); - const deserialized = t.parse(stringified); - - expect(deserialized).toEqual(expected); - } - } -}); - -test("guard unwanted objects", () => { - // Sets are okay, but not Maps - const t = setup({ - types: { - Set: tsonSet, - // defined last so it runs last - unknownObjectGuard: tsonUnknown, - }, - }); - - { - // sets are okay - - const expected = new Set([1]); - - const stringified = t.stringify(expected); - const deserialized = t.parse(stringified); - - expect(deserialized).toEqual(expected); - } - - { - // plain objects are okay - const expected = { a: 1 }; - const stringified = t.stringify(expected); - const deserialized = t.parse(stringified); - expect(deserialized).toEqual(expected); - } - - { - // maps are not okay - - const expected = new Map([["a", 1]]); - - const err = expectError(() => t.parse(t.stringify(expected))); - assert(err instanceof UnknownObjectGuardError); - - expect(err).toMatchInlineSnapshot( - "[UnknownObjectGuardError: Unknown object found]", - ); - expect(err.value).toEqual(expected); - } -}); - -test("regex", () => { - const t = setup({ - types: { - RegExp: tsonRegExp, - }, - }); - - const expected = /foo/g; - - const stringified = t.stringify(expected, 2); - - expect(stringified).toMatchInlineSnapshot( - ` - "{ - \\"json\\": [ - \\"RegExp\\", - \\"/foo/g\\", - \\"__tson\\" - ], - \\"nonce\\": \\"__tson\\" - }" - `, - ); - - const deserialized = t.parse(stringified); - - expect(deserialized).toBeInstanceOf(RegExp); - expect(deserialized).toMatchInlineSnapshot("/foo/g"); - expect(deserialized + "").toEqual(expected + ""); -}); - -test("lets have a look at the stringified output", () => { - const t = setup({ - types: { - Map: tsonMap, - Set: tsonSet, - bigint: tsonBigint, - undefined: tsonUndefined, - }, - }); - - const expected = new Set([ - // - 1, - "string", - undefined, - null, - true, - false, - 1n, - new Map([["foo", "bar"]]), - ]); - - const stringified = t.stringify(expected, 2); - - expect(stringified).toMatchInlineSnapshot(` - "{ - \\"json\\": [ - \\"Set\\", - [ - 1, - \\"string\\", - [ - \\"undefined\\", - 0, - \\"__tson\\" - ], - null, - true, - false, - [ - \\"bigint\\", - \\"1\\", - \\"__tson\\" - ], - [ - \\"Map\\", - [ - [ - \\"foo\\", - \\"bar\\" - ] - ], - \\"__tson\\" - ] - ], - \\"__tson\\" - ], - \\"nonce\\": \\"__tson\\" - }" - `); - - const deserialized = t.parse(stringified); - - expect(deserialized).toEqual(expected); -}); - -test("types", () => { - const t = setup({ - types: { - bigint: tsonBigint, - }, - }); - - const expected = 1n; - { - const stringified = t.stringify(expected); - // ^? - const parsed = t.parse(stringified); - // ^? - - expectTypeOf(parsed).toEqualTypeOf(expected); - } - - { - const serialized = t.serializer(expected); - // ^? - const deserialized = t.deserialize(serialized); - // ^? - - expectTypeOf(deserialized).toEqualTypeOf(expected); - } -}); diff --git a/src/tson.ts b/src/tson.ts index 0cc941e6..158f8812 100644 --- a/src/tson.ts +++ b/src/tson.ts @@ -25,14 +25,29 @@ function isTsonTuple(v: unknown, nonce: string): v is TsonTuple { type WalkFn = (value: unknown) => unknown; type WalkerFactory = (nonce: TsonNonce) => WalkFn; +type AnyTsonTransformerSerializeDeserialize = + TsonTransformerSerializeDeserialize; + export function tsonDeserializer(opts: TsonOptions): TsonDeserializeFn { + const typeByKey: Record = {}; + + for (const handler of opts.types) { + if (handler.key) { + if (typeByKey[handler.key]) { + throw new Error(`Multiple handlers for key ${handler.key} found`); + } + + typeByKey[handler.key] = + handler as AnyTsonTransformerSerializeDeserialize; + } + } + const walker: WalkerFactory = (nonce) => { const walk: WalkFn = (value) => { if (isTsonTuple(value, nonce)) { const [type, serializedValue] = value; - const transformer = opts.types[ - type - ] as TsonTransformerSerializeDeserialize; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const transformer = typeByKey[type]!; return transformer.deserialize(walk(serializedValue)); } @@ -62,21 +77,17 @@ export function tsonStringifier(opts: TsonOptions): TsonStringifyFn { export function tsonSerializer(opts: TsonOptions): TsonSerializeFn { const handlers = (() => { - // warmup the type handlers - const types = Object.entries(opts.types).map(([_key, handler]) => { - const key = _key as TsonTypeHandlerKey; - const serialize = handler.serialize; - + const types = opts.types.map((handler) => { type Serializer = ( value: unknown, nonce: TsonNonce, walk: WalkFn, ) => TsonSerializedValue; - const $serialize: Serializer = serialize + const $serialize: Serializer = handler.serialize ? (value, nonce, walk): TsonTuple => [ - key, - walk(serialize(value)), + handler.key as TsonTypeHandlerKey, + walk(handler.serialize(value)), nonce, ] : (value, _nonce, walk) => walk(value); @@ -87,37 +98,36 @@ export function tsonSerializer(opts: TsonOptions): TsonSerializeFn { }); type Handler = (typeof types)[number]; - const handlerPerPrimitive: Partial< + const byPrimitive: Partial< Record> > = {}; - const customTypeHandlers: Extract[] = []; + const nonPrimitive: Extract[] = []; for (const handler of types) { if (handler.primitive) { - if (handlerPerPrimitive[handler.primitive]) { + if (byPrimitive[handler.primitive]) { throw new Error( `Multiple handlers for primitive ${handler.primitive} found`, ); } - handlerPerPrimitive[handler.primitive] = handler; + byPrimitive[handler.primitive] = handler; } else { - customTypeHandlers.push(handler); + nonPrimitive.push(handler); } } - return { - custom: customTypeHandlers, - primitive: handlerPerPrimitive, - }; + return [nonPrimitive, byPrimitive] as const; })(); const maybeNonce = opts.nonce; + const [nonPrimitive, byPrimitive] = handlers; + const walker: WalkerFactory = (nonce) => { const walk: WalkFn = (value) => { const type = typeof value; - const primitiveHandler = handlers.primitive[type]; + const primitiveHandler = byPrimitive[type]; if ( primitiveHandler && (!primitiveHandler.test || primitiveHandler.test(value)) @@ -125,7 +135,7 @@ export function tsonSerializer(opts: TsonOptions): TsonSerializeFn { return primitiveHandler.$serialize(value, nonce, walk); } - for (const handler of handlers.custom) { + for (const handler of nonPrimitive) { if (handler.test(value)) { return handler.$serialize(value, nonce, walk); } @@ -175,3 +185,10 @@ function mapOrReturn( return input; } + +export const createTupleson = (opts: TsonOptions) => ({ + deserialize: tsonDeserializer(opts), + parse: tsonParser(opts), + serializer: tsonSerializer(opts), + stringify: tsonStringifier(opts), +}); diff --git a/src/types.test.ts b/src/types.test.ts new file mode 100644 index 00000000..5bfd9a8b --- /dev/null +++ b/src/types.test.ts @@ -0,0 +1,27 @@ +import { expectTypeOf, test } from "vitest"; + +import { tsonBigint } from "./handlers/tsonBigint.js"; +import { createTupleson } from "./tson.js"; + +test("types", () => { + const t = createTupleson({ + types: [tsonBigint], + }); + + const expected = 1n; + { + const stringified = t.stringify(expected); + // ^? + const parsed = t.parse(stringified); + // ^? + expectTypeOf(parsed).toEqualTypeOf(expected); + } + + { + const serialized = t.serializer(expected); + // ^? + const deserialized = t.deserialize(serialized); + // ^? + expectTypeOf(deserialized).toEqualTypeOf(expected); + } +}); diff --git a/src/types.ts b/src/types.ts index c5412fad..a03940f9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,11 +31,11 @@ type SerializedType = export interface TsonTransformerNone { deserialize?: never; - serialize?: never; /** - * Won't be deserialized nor serialized + * The key to use when serialized */ - transform: false; + key?: never; + serialize?: never; } export interface TsonTransformerSerializeDeserialize< TValue, @@ -47,13 +47,13 @@ export interface TsonTransformerSerializeDeserialize< deserialize: (v: TSerializedType) => TValue; /** - * JSON-serializable value + * The key to use when serialized */ - serialize: (v: TValue) => TSerializedType; + key: string; /** - * Use a transformer to serialize and deserialize the value? + * JSON-serializable value */ - transform?: true; + serialize: (v: TValue) => TSerializedType; } export type TsonTransformer = @@ -96,7 +96,7 @@ export type TsonType< export interface TsonOptions { nonce?: () => string; - types: Record | TsonType>; + types: (TsonType | TsonType)[]; } const serialized = Symbol("serialized"); diff --git a/tsconfig.json b/tsconfig.json index d8e71da0..8195dadc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,10 +7,9 @@ "moduleResolution": "NodeNext", "outDir": "lib", "resolveJsonModule": true, - "skipLibCheck": true, "sourceMap": true, - "strict": true, "target": "ES2022" }, + "extends": "@tsconfig/strictest/tsconfig.json", "include": ["src"] }