diff --git a/cspell.json b/cspell.json index b29d9f1..1734426 100644 --- a/cspell.json +++ b/cspell.json @@ -26,16 +26,17 @@ "knip", "lcov", "markdownlintignore", + "marshaller", "npmpackagejsonlintrc", "openai", "outro", "packagejson", "quickstart", - "Streamified", - "Streamify", + "streamified", + "streamify", "stringifier", "superjson", - "Thunkable", + "thunkable", "tson", "tsup", "tupleson", diff --git a/src/async/asyncHelpers.ts b/src/async/asyncHelpers.ts index 19ac226..2513b77 100644 --- a/src/async/asyncHelpers.ts +++ b/src/async/asyncHelpers.ts @@ -10,17 +10,17 @@ export async function* mapIterable( export async function reduceIterable< T, TInitialValue extends Promise = Promise, - TKey extends PropertyKey = number, - TKeyFn extends (prev: TKey) => TKey = (prev: TKey) => TKey, + TKey extends PropertyKey | bigint = bigint, + TKeyFn extends (prev?: TKey) => TKey = (prev?: TKey) => TKey, >( - iterable: AsyncIterable, + iterable: Iterable, fn: (acc: Awaited, v: T, i: TKey) => Awaited, initialValue: TInitialValue = Promise.resolve() as TInitialValue, - initialKey: TKey = 0 as TKey, - incrementKey: TKeyFn = ((prev) => (prev as number) + 1) as TKeyFn, + incrementKey: TKeyFn = ((prev?: bigint) => + prev === undefined ? 0n : prev + 1n) as TKeyFn, ): Promise> { let acc = initialValue; - let i = initialKey; + let i = incrementKey(); for await (const value of iterable) { acc = fn(await acc, value, i); diff --git a/src/async/deserializeAsync.ts b/src/async/deserializeAsync.ts index 05c328a..c13b1b5 100644 --- a/src/async/deserializeAsync.ts +++ b/src/async/deserializeAsync.ts @@ -180,7 +180,7 @@ function createTsonDeserializer(opts: TsonAsyncOptions) { const walk = walker(head.nonce); try { - const walked = walk(head.tson); + const walked = walk(head.json); return walked; } finally { diff --git a/src/async/iterableUtils.ts b/src/async/iterableUtils.ts index b368abc..8095410 100644 --- a/src/async/iterableUtils.ts +++ b/src/async/iterableUtils.ts @@ -158,7 +158,7 @@ export interface AsyncIterableEsque { export function isAsyncIterableEsque( maybeAsyncIterable: unknown, -): maybeAsyncIterable is AsyncIterableEsque & AsyncIterable { +): maybeAsyncIterable is AsyncIterableEsque { return ( !!maybeAsyncIterable && (typeof maybeAsyncIterable === "object" || @@ -167,8 +167,8 @@ export function isAsyncIterableEsque( ); } -export interface IterableEsque { - [Symbol.iterator](): unknown; +export interface IterableEsque { + [Symbol.iterator](): Iterator; } export function isIterableEsque( @@ -182,11 +182,11 @@ export function isIterableEsque( ); } -type GeneratorFnEsque = (() => AsyncGenerator) | (() => Generator); +type SyncOrAsyncGeneratorFnEsque = AsyncGeneratorFnEsque | GeneratorFnEsque; -export function isAsyncGeneratorFn( +export function isMaybeAsyncGeneratorFn( maybeAsyncGeneratorFn: unknown, -): maybeAsyncGeneratorFn is GeneratorFnEsque { +): maybeAsyncGeneratorFn is SyncOrAsyncGeneratorFnEsque { return ( typeof maybeAsyncGeneratorFn === "function" && ["AsyncGeneratorFunction", "GeneratorFunction"].includes( @@ -195,6 +195,28 @@ export function isAsyncGeneratorFn( ); } +export type GeneratorFnEsque = () => Generator; + +export function isGeneratorFnEsque( + maybeGeneratorFn: unknown, +): maybeGeneratorFn is GeneratorFnEsque { + return ( + typeof maybeGeneratorFn === "function" && + maybeGeneratorFn.constructor.name === "GeneratorFunction" + ); +} + +export type AsyncGeneratorFnEsque = () => AsyncGenerator; + +export function isAsyncGeneratorFnEsque( + maybeAsyncGeneratorFn: unknown, +): maybeAsyncGeneratorFn is AsyncGeneratorFnEsque { + return ( + typeof maybeAsyncGeneratorFn === "function" && + maybeAsyncGeneratorFn.constructor.name === "AsyncGeneratorFunction" + ); +} + export type PromiseEsque = PromiseLike; export function isPromiseEsque( @@ -218,9 +240,9 @@ export function isThunkEsque(maybeThunk: unknown): maybeThunk is ThunkEsque { export type Thunkable = | AsyncIterableEsque - | GeneratorFnEsque | IterableEsque | PromiseEsque + | SyncOrAsyncGeneratorFnEsque | ThunkEsque; export type MaybePromise = Promise | T; diff --git a/src/index.test.ts b/src/index.test.ts index 21d5286..4eee848 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -5,7 +5,7 @@ import { TsonOptions, TsonType, createTson } from "./index.js"; import { expectError, waitError } from "./internals/testUtils.js"; test("multiple handlers for primitive string found", () => { - const stringHandler: TsonType = { + const stringHandler: TsonType = { primitive: "string", }; const opts: TsonOptions = { @@ -98,7 +98,7 @@ test("async: duplicate keys", async () => { }); test("async: multiple handlers for primitive string found", async () => { - const stringHandler: TsonType = { + const stringHandler: TsonType = { primitive: "string", }; diff --git a/src/internals/isComplexValue.ts b/src/internals/isComplexValue.ts new file mode 100644 index 0000000..95ed14d --- /dev/null +++ b/src/internals/isComplexValue.ts @@ -0,0 +1,3 @@ +export function isComplexValue(arg: unknown): arg is object { + return arg !== null && (typeof arg === "object" || typeof arg === "function"); +} diff --git a/src/internals/isPlainObject.ts b/src/internals/isPlainObject.ts index 8f47a39..eeb16f2 100644 --- a/src/internals/isPlainObject.ts +++ b/src/internals/isPlainObject.ts @@ -1,4 +1,6 @@ -export const isPlainObject = (obj: unknown): obj is Record => { +export const isPlainObject = ( + obj: unknown, +): obj is Record => { if (!obj || typeof obj !== "object") { return false; } diff --git a/src/iterableTypes.test.ts b/src/iterableTypes.test.ts index be0f339..aeee83a 100644 --- a/src/iterableTypes.test.ts +++ b/src/iterableTypes.test.ts @@ -1,13 +1,6 @@ import * as v from "vitest"; -import { - AsyncGenerator, - AsyncIterable, - AsyncIterableIterator, - Generator, - Iterable, - IterableIterator, -} from "./iterableTypes.js"; +import { AsyncGenerator, Generator } from "./iterableTypes.js"; v.describe("Async Iterable Types", () => { v.it("should be interchangeable with the original type signatures", () => { diff --git a/src/sync/deserialize.ts b/src/sync/deserialize.ts index d4732e6..74f312c 100644 --- a/src/sync/deserialize.ts +++ b/src/sync/deserialize.ts @@ -1,6 +1,5 @@ import { isTsonTuple } from "../internals/isTsonTuple.js"; import { mapOrReturn } from "../internals/mapOrReturn.js"; -import { TsonAssert, TsonGuard } from "../tsonAssert.js"; import { TsonDeserializeFn, TsonMarshaller, @@ -17,7 +16,6 @@ type AnyTsonMarshaller = TsonMarshaller; export function createTsonDeserialize(opts: TsonOptions): TsonDeserializeFn { const typeByKey: Record = {}; - const assertions: TsonGuard[] = []; for (const handler of opts.types) { if (handler.key) { if (typeByKey[handler.key]) { @@ -26,20 +24,12 @@ export function createTsonDeserialize(opts: TsonOptions): TsonDeserializeFn { typeByKey[handler.key] = handler as AnyTsonMarshaller; } - - if ("assertion" in handler) { - assertions.push(handler); - continue; - } } const walker: WalkerFactory = (nonce) => { const walk: WalkFn = (value) => { if (isTsonTuple(value, nonce)) { const [type, serializedValue] = value; - for (const assert of assertions) { - assert.assertion(value); - } // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const transformer = typeByKey[type]!; diff --git a/src/sync/handlers/tsonNumberGuard.test.ts b/src/sync/handlers/tsonNumberGuard.test.ts index b571351..3778ca5 100644 --- a/src/sync/handlers/tsonNumberGuard.test.ts +++ b/src/sync/handlers/tsonNumberGuard.test.ts @@ -5,7 +5,8 @@ import { expectError } from "../../internals/testUtils.js"; test("number", () => { const t = createTson({ - types: [tsonNumberGuard], + guards: [tsonNumberGuard], + types: [], }); const bad = [ diff --git a/src/sync/handlers/tsonNumberGuard.ts b/src/sync/handlers/tsonNumberGuard.ts index d4bc986..5e569b7 100644 --- a/src/sync/handlers/tsonNumberGuard.ts +++ b/src/sync/handlers/tsonNumberGuard.ts @@ -1,19 +1,22 @@ -import { tsonAssert } from "../../tsonAssert.js"; +import { TsonGuard } from "../../tsonAssert.js"; /** * Prevents `NaN` and `Infinity` from being serialized */ -export const tsonAssertNotInfinite = tsonAssert((v) => { - if (typeof v !== "number") { - return; - } +export const tsonNumberGuard: TsonGuard> = { + assert(v: unknown) { + if (typeof v !== "number") { + return; + } - if (isNaN(v)) { - throw new Error("Encountered NaN"); - } + if (isNaN(v)) { + throw new Error("Encountered NaN"); + } - if (!isFinite(v)) { - throw new Error("Encountered Infinity"); - } -}); + if (!isFinite(v)) { + throw new Error("Encountered Infinity"); + } + }, + key: "tsonAssertNotInfinite", +}; diff --git a/src/sync/handlers/tsonUnknownObjectGuard.test.ts b/src/sync/handlers/tsonUnknownObjectGuard.test.ts index 0739c85..b7d0389 100644 --- a/src/sync/handlers/tsonUnknownObjectGuard.test.ts +++ b/src/sync/handlers/tsonUnknownObjectGuard.test.ts @@ -11,11 +11,8 @@ import { expectError } from "../../internals/testUtils.js"; test("guard unwanted objects", () => { // Sets are okay, but not Maps const t = createTson({ - types: [ - tsonSet, - // defined last so it runs last - tsonUnknownObjectGuard, - ], + guards: [tsonUnknownObjectGuard], + types: [tsonSet], }); { diff --git a/src/sync/handlers/tsonUnknownObjectGuard.ts b/src/sync/handlers/tsonUnknownObjectGuard.ts index 78d995b..6116e00 100644 --- a/src/sync/handlers/tsonUnknownObjectGuard.ts +++ b/src/sync/handlers/tsonUnknownObjectGuard.ts @@ -1,6 +1,6 @@ import { TsonError } from "../../errors.js"; import { isPlainObject } from "../../internals/isPlainObject.js"; -import { TsonGuard, tsonAssert } from "../../tsonAssert.js"; +import { TsonGuard } from "../../tsonAssert.js"; export class TsonUnknownObjectGuardError extends TsonError { /** @@ -24,8 +24,11 @@ export class TsonUnknownObjectGuardError extends TsonError { * Make sure to define this last in the list of types. * @throws {TsonUnknownObjectGuardError} if an unknown object is found */ -export const tsonUnknownObjectGuard = tsonAssert((v) => { - if (v && typeof v === "object" && !Array.isArray(v) && !isPlainObject(v)) { - throw new TsonUnknownObjectGuardError(v); - } -}); +export const tsonUnknownObjectGuard: TsonGuard> = { + assert(v: unknown) { + if (v && typeof v === "object" && !Array.isArray(v) && !isPlainObject(v)) { + throw new TsonUnknownObjectGuardError(v); + } + }, + key: "tsonUnknownObjectGuard", +}; diff --git a/src/sync/serialize.ts b/src/sync/serialize.ts index 1becd80..659dcc7 100644 --- a/src/sync/serialize.ts +++ b/src/sync/serialize.ts @@ -1,7 +1,9 @@ import { TsonCircularReferenceError } from "../errors.js"; import { GetNonce, getDefaultNonce } from "../internals/getNonce.js"; +import { isComplexValue } from "../internals/isComplexValue.js"; import { mapOrReturn } from "../internals/mapOrReturn.js"; import { + SerializedType, TsonAllTypes, TsonNonce, TsonOptions, @@ -9,6 +11,7 @@ import { TsonSerialized, TsonStringifyFn, TsonTuple, + TsonTypeHandlerKey, TsonTypeTesterCustom, TsonTypeTesterPrimitive, } from "./syncTypes.js"; @@ -19,30 +22,32 @@ type WalkerFactory = (nonce: TsonNonce) => WalkFn; function getHandlers(opts: TsonOptions) { type Handler = (typeof opts.types)[number]; - const byPrimitive: Partial< - Record> - > = {}; - const nonPrimitives: Extract[] = []; + const primitives = new Map< + TsonAllTypes, + Extract + >(); - for (const handler of opts.types) { - if (handler.primitive) { - if (byPrimitive[handler.primitive]) { + const customs = new Set>(); + + for (const marshaller of opts.types) { + if (marshaller.primitive) { + if (primitives.has(marshaller.primitive)) { throw new Error( - `Multiple handlers for primitive ${handler.primitive} found`, + `Multiple handlers for primitive ${marshaller.primitive} found`, ); } - byPrimitive[handler.primitive] = handler; + primitives.set(marshaller.primitive, marshaller); } else { - nonPrimitives.push(handler); + customs.add(marshaller); } } - const getNonce: GetNonce = opts.nonce - ? (opts.nonce as GetNonce) - : getDefaultNonce; + const getNonce = (opts.nonce ? opts.nonce : getDefaultNonce) as GetNonce; + + const guards = opts.guards ?? []; - return [getNonce, nonPrimitives, byPrimitive] as const; + return [getNonce, customs, primitives, guards] as const; } export function createTsonStringify(opts: TsonOptions): TsonStringifyFn { @@ -53,65 +58,73 @@ export function createTsonStringify(opts: TsonOptions): TsonStringifyFn { } export function createTsonSerialize(opts: TsonOptions): TsonSerializeFn { - const [getNonce, nonPrimitive, byPrimitive] = getHandlers(opts); + const [getNonce, nonPrimitives, primitives, guards] = getHandlers(opts); const walker: WalkerFactory = (nonce) => { const seen = new WeakSet(); const cache = new WeakMap(); - const walk: WalkFn = (value) => { + const walk: WalkFn = (value: unknown) => { const type = typeof value; - const isComplex = !!value && type === "object"; - if (isComplex) { - if (seen.has(value)) { - const cached = cache.get(value); - if (!cached) { - throw new TsonCircularReferenceError(value); - } + const primitiveHandler = primitives.get(type); - return cached; + const handler = + primitiveHandler && + (!primitiveHandler.test || primitiveHandler.test(value)) + ? primitiveHandler + : Array.from(nonPrimitives).find((handler) => handler.test(value)); + + if (!handler) { + for (const guard of guards) { + // if ("assert" in guard) { + guard.assert(value); + // } + //todo: if this is implemented does it go before or after assert? + // if ("parse" in guard) { + // value = guard.parse(value); + // } } - seen.add(value); + return mapOrReturn(value, walk); } - const cacheAndReturn = (result: unknown) => { - if (isComplex) { - cache.set(value, result); - } - - return result; - }; + if (!isComplexValue(value)) { + return toTuple(value, handler); + } - const primitiveHandler = byPrimitive[type]; - if ( - primitiveHandler && - (!primitiveHandler.test || primitiveHandler.test(value)) - ) { - return cacheAndReturn([ - primitiveHandler.key, + // if this is a value-by-reference we've seen before, either: + // - We've serialized & cached it before and can return the cached value + // - We're attempting to serialize it, but one of its children is itself (circular reference) + if (cache.has(value)) { + return cache.get(value); + } - walk(primitiveHandler.serialize(value)), - nonce, - ] as TsonTuple); + if (seen.has(value)) { + throw new TsonCircularReferenceError(value); } - for (const handler of nonPrimitive) { - if (handler.test(value)) { - return cacheAndReturn([ - handler.key, + seen.add(value); - walk(handler.serialize(value)), - nonce, - ] as TsonTuple); - } - } + const tuple = toTuple(value, handler); - return cacheAndReturn(mapOrReturn(value, walk)); + cache.set(value, tuple); + + return tuple; }; return walk; + + function toTuple( + v: unknown, + handler: { key: string; serialize: (arg: unknown) => SerializedType }, + ) { + return [ + handler.key as TsonTypeHandlerKey, + walk(handler.serialize(v)), + nonce, + ] as TsonTuple; + } }; return ((obj): TsonSerialized => { diff --git a/src/sync/syncTypes.ts b/src/sync/syncTypes.ts index 69046bc..bf574a2 100644 --- a/src/sync/syncTypes.ts +++ b/src/sync/syncTypes.ts @@ -19,14 +19,13 @@ export type TsonAllTypes = | "string" | "symbol" | "undefined"; -// Should this not be a recursive type? Any serialized objects should all have -// be json-serializable, right? + export type SerializedType = - | { [key: string]: SerializedType } - | SerializedType[] + | Record | boolean | number - | string; + | string + | unknown[]; export interface TsonMarshaller< TValue, diff --git a/src/tsonAssert.ts b/src/tsonAssert.ts index 815a93e..efff045 100644 --- a/src/tsonAssert.ts +++ b/src/tsonAssert.ts @@ -12,7 +12,7 @@ export type IsUnknown = [unknown] extends [T] ? Not> : false; * What is this API, really? What is the goal? * Is it to provide a way to assert that a value is of a certain type? I think * this only makes sense in a few limited cases. - * + * * At what point in the pipeline would this occur? Would this happen for all * values? If so, well... you've asserted that your parser only handles * the type you're asserting. I don't know why you'd want to predetermine @@ -33,65 +33,33 @@ export type IsUnknown = [unknown] extends [T] ? Not> : false; * we're marshalling. We assume the type layer is accurate, without * enforcing it. If we want to integrate runtime type validation, that * seems like a feature request, potentially warranting it's own API. - * + * * Ultimately, I feel like this functionality is easily served by a simple * assertion function that throws for invalid values. For most (all?) except * for the unknown object guard they would come first in the array, while * the unknown object guard would come last. */ -export interface TsonGuard { - /** - * A type assertion that narrows the type of the value - */ - assertion:

( - v: P, - ) => asserts v is IsAny

extends true - ? T extends infer N extends P - ? N - : T extends P - ? T - : P & T - : never; - /** - * A unique identifier for this assertion - */ +interface TsonGuardBase { key: string; } - -interface ValidationParser { - parse: (...args: unknown[]) => TValue; -} - -export interface TsonAssert { - is: , TValue = unknown>( - schema: TSchema, - ) => TsonGuard>; - (assertFn: ValidationParser["parse"]): TsonGuard; +interface TsonAssertionGuard extends TsonGuardBase { + /** + * @param v - The value to assert + * @returns `void | true` if the value is of the type + * @returns `false` if the value is not of the type + * @throws `any` if the value is not of the type + */ + assert: ((v: any) => asserts v is T) | ((v: any) => v is T); } -/** - * @param assertFn - A type assertion that narrows the type of the value - * @function tsonAssert["is"] - returns a TsonGuard from a validation schema - * @returns a new TsonGuard for use in configuring TSON. - * The key of the guard is the name of the assertion function. - */ - -const tsonAssert = (assertFn: (v: unknown) => asserts v is TValue) => - ({ - assertion: assertFn, - key: assertFn.name, - }) satisfies TsonGuard; -/** - * @param schema - A validation schema with a 'parse' method that throws an error - * if the value is invalid - * @returns a new TsonGuard for use in configuring TSON. - */ -tsonAssert.is = , TValue = unknown>( - schema: TSchema, -) => - ({ - assertion: schema.parse, - key: schema.parse.name, - }) satisfies TsonGuard>; +// // todo: maybe guard.parse can have guard.parse.input and guard.parse.output? +// interface TsonParserGuard extends TsonGuardBase { +// /** +// * +// * @param v - The value to parse +// * @returns {T} - A value that will be used in place of the original value +// */ +// parse: (v: any) => T; +// } -export { tsonAssert }; +export type TsonGuard = TsonAssertionGuard /* | TsonParserGuard */;