From edd6a68d9decd4fa064d15832fb6d2c03b486553 Mon Sep 17 00:00:00 2001 From: Anton Date: Tue, 23 Jun 2026 17:04:03 +0200 Subject: [PATCH] feat: more mixins --- src/base/hkt.ts | 4 +- src/base/index.ts | 13 +- src/base/mixin.ts | 43 ++-- src/internal.ts | 50 ++++- src/mixin/array.ts | 444 ++++++++++++++++++++++++++++++++++++++++++ src/mixin/awaited.ts | 123 ++++++++++-- src/mixin/base.ts | 6 +- src/mixin/index.ts | 3 +- src/mixin/optional.ts | 132 +++++++++++++ src/registry.ts | 15 +- 10 files changed, 771 insertions(+), 62 deletions(-) create mode 100644 src/mixin/array.ts create mode 100644 src/mixin/optional.ts diff --git a/src/base/hkt.ts b/src/base/hkt.ts index d6f447e..83a690a 100644 --- a/src/base/hkt.ts +++ b/src/base/hkt.ts @@ -1,6 +1,6 @@ export interface HKT { readonly _meta: In; - readonly _t: In; + readonly _t: unknown; new: (t: never) => Out; } @@ -12,7 +12,7 @@ type Param< export namespace HKT { export type T< This extends HKT, - T = This extends { _t: infer I } ? I : unknown, + T = This extends { _meta: infer I } ? I : unknown, > = Param; export type Apply = ReturnType< (T & { _t: t })["new"] diff --git a/src/base/index.ts b/src/base/index.ts index 258106e..54fa7bf 100644 --- a/src/base/index.ts +++ b/src/base/index.ts @@ -1,18 +1,21 @@ import type { DefaultRegistry, Methods, Registry } from "../registry"; +import type { shim } from "./mixin"; -export interface Base { - readonly value: T; -} +export type Base = T extends { [shim]: { value: infer U } } + ? { readonly value: U } + : { + readonly value: T; + }; export type Fluent< T, Reg extends Registry = DefaultRegistry, -> = Base & Methods & { readonly __registry: Reg }; +> = Base & Methods; export function makeFluent( registry: Reg, ) { - const fluent = (value: T) => { + const fluent = (value: T) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const f = { value } as never as Fluent; diff --git a/src/base/mixin.ts b/src/base/mixin.ts index 35d2e5b..13793a1 100644 --- a/src/base/mixin.ts +++ b/src/base/mixin.ts @@ -8,30 +8,19 @@ export interface Props { readonly meta: { registry: Registry }; } -type UnionToIntersection = ( - U extends unknown ? (x: U) => void : never -) extends (x: infer I) => void - ? I - : never; - type MixinHKT = HKT; -type MixinFn = ( +type MixinFn = ( value: unknown, - fluent: Partial< - UnionToIntersection< - // eslint-disable-next-line @typescript-eslint/no-explicit-any - HKT.Apply - > - >, + fluent: Record, callback: (value: unknown) => never, ) => void; export interface Mixin { hkt: T; - fn: MixinFn; + fn: MixinFn; } -export function Mixin(fn: MixinFn): Mixin { +export function Mixin(fn: MixinFn): Mixin { return { hkt: never, fn, @@ -40,18 +29,26 @@ export function Mixin(fn: MixinFn): Mixin { export namespace Mixin { export type HKT = MixinHKT; - export type Function = MixinFn; + export type Function = MixinFn; } -// type Fluent = _Fluent< -// T, -// P["meta"]["registry"] -// > & {}; +export declare const shim: unique symbol; +export interface Shim { + input?: unknown; + output?: HKT; + value?: unknown; +} -export type Input

= P["value"]; +export type Input

= P["value"] extends infer T + ? T extends { [shim]: { input: infer I } } + ? I + : T + : never; export type Return = Fluent< - T, - P extends { meta: { registry: infer Reg } } ? Reg : never + P["value"] extends { [shim]: { output: infer U extends HKT } } + ? HKT.Apply + : T, + P["meta"]["registry"] >; export type Instansiate< diff --git a/src/internal.ts b/src/internal.ts index bfd9ae0..10b2b7b 100644 --- a/src/internal.ts +++ b/src/internal.ts @@ -9,5 +9,51 @@ export const never = undefined as never; export type Constraint = U; -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export type Empty = Constraint, {}>; +export class AssertionError extends Error { + public constructor(msg?: string) { + super(msg); + this.name = "AssertionError"; + } +} + +export function assert( + condition: unknown, + msg?: string, +): asserts condition { + if (!Boolean(condition)) + throw new AssertionError( + ["Assertion error", msg] + .filter((v) => v !== undefined) + .join(": "), + ); +} + +// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters, @typescript-eslint/no-unused-vars +export function assertType(_v: unknown): asserts _v is T { + /* empty */ +} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +type FunctionProto = Function; +export interface HidePrototype { + /** @deprecated */ + apply: FunctionProto["apply"]; + /** @deprecated */ + arguments: FunctionProto["arguments"]; + /** @deprecated */ + bind: FunctionProto["bind"]; + /** @deprecated */ + call: FunctionProto["call"]; + /** @deprecated */ + caller: FunctionProto["caller"]; + /** @deprecated */ + length: FunctionProto["length"]; + /** @deprecated */ + name: FunctionProto["name"]; + /** @deprecated */ + prototype: FunctionProto["prototype"]; + /** @deprecated */ + toString: FunctionProto["toString"]; + /** @deprecated */ + Symbol: SymbolConstructor; +} diff --git a/src/mixin/array.ts b/src/mixin/array.ts new file mode 100644 index 0000000..381f4fc --- /dev/null +++ b/src/mixin/array.ts @@ -0,0 +1,444 @@ +import { makeFluent } from "../base"; +import type { HKT } from "../base/hkt"; +import { Mixin, type Input, type Return } from "../base/mixin"; +import { assert, assertType, type HidePrototype } from "../internal"; + +type IterArgs = [ + element: T[number], + idx: number, + array: Readonly, +]; + +type MaxDepth = 20; + +type Unordered = T extends unknown[] + ? T[number][] + : readonly T[number][]; + +type Map< + T extends readonly unknown[], + U, + TOut extends U[] = [], +> = TOut["length"] extends MaxDepth + ? U[] + : T extends readonly [infer T, ...infer TRest extends unknown[]] + ? Map + : T extends readonly [] + ? TOut + : U[]; + +type Reverse< + T extends readonly unknown[], + TOut extends readonly T[number][] = [], +> = TOut["length"] extends MaxDepth + ? T[number][] + : T extends readonly [...infer TRest extends unknown[], infer T] + ? Reverse + : T extends readonly [] + ? TOut + : T[number][]; + +namespace At { + export type Dec = [ + -1, + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + ][N]; + + export type Select< + T extends readonly unknown[], + Skip extends number, + > = Skip extends 0 + ? T extends readonly [...unknown[], infer T] + ? T + : undefined + : T extends readonly [...infer TRest, unknown] + ? Select> + : undefined; +} + +type At< + T extends readonly unknown[], + K extends keyof T & number, +> = `${K}` extends `-${infer N extends number}` + ? At.Select> + : T[K]; + +namespace Flat { + type Inc< + N extends number, + Acc extends unknown[] = [], + > = Acc["length"] extends N + ? [...Acc, null]["length"] + : Inc; + + export type Leaves< + T, + Depth extends number = 0, + > = Depth extends MaxDepth + ? T + : T extends readonly unknown[] + ? Leaves> + : T; + + export type Rebuild< + T extends readonly unknown[], + Depth extends number = 0, + > = Depth extends MaxDepth + ? T + : T extends readonly [infer H, ...infer R] + ? H extends readonly unknown[] + ? [...Rebuild>, ...Rebuild] + : [H, ...Rebuild] + : []; +} + +export type Flat = + number extends T["length"] ? Flat.Leaves[] : Flat.Rebuild; + +function isArray(v: unknown): v is readonly unknown[] { + return typeof v === "object" && globalThis.Array.isArray(v); +} + +export interface Array extends Mixin.HKT { + new: (t: HKT.T) => Input extends infer T + ? T extends readonly (infer Item)[] + ? { + at: ( + index: K, + ) => Return, typeof t>; + each: ( + callback: (...args: IterArgs) => void, + ) => Return; + map: ( + callback: (...args: IterArgs) => U, + ) => Return, typeof t>; + filter: ( + callback: (...args: IterArgs) => boolean, + ) => Return, typeof t>; + reduce: (( + initial: U, + callback: ( + value: U, + ...args: IterArgs + ) => U, + ) => Return) & { + right: ( + initial: U, + callback: ( + value: U, + ...args: IterArgs + ) => U, + ) => Return; + } & HidePrototype; + length: () => Return; + sort: (( + callback: ( + a: Item, + b: Item, + arr: Readonly, + ) => number, + ) => Return, typeof t>) & + ([Item] extends [string] + ? { + alpha: () => Return< + Unordered, + typeof t + >; + } + : { + alpha: ( + via: (v: Item) => string, + ) => Return< + Unordered, + typeof t + >; + }) & + ([Item] extends [number] + ? { + ascending: () => Return< + Unordered, + typeof t + >; + descending: () => Return< + Unordered, + typeof t + >; + } + : { + ascending: ( + via: (v: Item) => number, + ) => Return< + Unordered, + typeof t + >; + descending: ( + via: (v: Item) => number, + ) => Return< + Unordered, + typeof t + >; + }) & + HidePrototype; + reverse: () => Return, typeof t>; + } + : unknown + : never; +} +export const Array = Mixin((value, $, fluent) => { + if (!isArray(value)) return; + + $.at = (index: number) => { + return fluent(value.at(index)); + }; + + $.each = (callback: (...args: IterArgs) => void) => { + value.forEach(callback); + return fluent(value); + }; + + $.map = (callback: (...args: IterArgs) => unknown) => { + return fluent(value.map(callback)); + }; + + $.filter = (callback: (...args: IterArgs) => boolean) => { + return fluent(value.filter(callback)); + }; + + $.reduce = ( + initial: unknown, + callback: (value: unknown, ...args: IterArgs) => unknown, + ) => { + return fluent(value.reduce(callback, initial)); + }; + assertType($.reduce); + Object.assign($.reduce, { + right: ( + initial: unknown, + callback: (value: unknown, ...args: IterArgs) => unknown, + ) => { + return fluent(value.reduceRight(callback, initial)); + }, + }); + + $.length = () => { + return fluent(value.length); + }; + + $.sort = ( + callback: ( + a: unknown, + b: unknown, + arr: readonly unknown[], + ) => number, + ) => { + return fluent( + value.toSorted((a, b) => callback(a, b, value)), + ); + }; + assertType($.sort); + Object.assign($.sort, { + alpha: ( + via: (v: unknown) => string = (v) => ( + assert(typeof v === "string"), + v + ), + ) => { + return fluent( + value.toSorted((a, b) => + via(a)! < via(b)! ? -1 : 1, + ), + ); + }, + ascending: ( + via: (v: unknown) => number = (v) => ( + assert(typeof v === "number"), + v + ), + ) => { + return fluent(value.toSorted((a, b) => via(a) - via(b))); + }, + descending: ( + via: (v: unknown) => number = (v) => ( + assert(typeof v === "number"), + v + ), + ) => { + return fluent(value.toSorted((a, b) => via(b) - via(a))); + }, + }); + + $.reverse = () => { + return fluent(value.toReversed()); + }; +}); + +if (import.meta.vitest) { + const { test, expect, vi } = import.meta.vitest; + + const registry = [Array] as const; + const $ = makeFluent(registry); + + test("at()", () => { + const arr = [1, 2, 3] as const; + + expect($(arr).at(0).value).toBe(arr[0]); + expect($(arr).at(5).value).toBe(undefined); + + expect($(arr).at(-1).value).toBe(arr[arr.length - 1]); + expect($(arr).at(-5).value).toBe(undefined); + }); + + test("each()", () => { + const arr = [5, 7, 1] as const; + const callback = vi.fn(); + + const result = $(arr).each(callback).value; + + expect(result).toMatchObject(arr); + + for (let i = 0; i < arr.length; i++) + expect(callback).toHaveBeenNthCalledWith( + i + 1, + arr[i], + i, + arr, + ); + }); + + test("map()", () => { + const arr = [1, 2, 3, 4, 5] as const; + const isEven = vi.fn((v: number) => v % 2 === 0); + + expect($(arr).map(isEven).value).toMatchObject( + arr.map(isEven), + ); + + for (let i = 0; i < arr.length; i++) + expect(isEven).toHaveBeenNthCalledWith( + i + 1, + arr[i], + i, + arr, + ); + }); + + test("filter()", () => { + const arr = [5, 4, 3, 2, 1] as const; + const isEven = vi.fn((v: number) => v % 2 === 0); + + expect($(arr).filter(isEven).value).toMatchObject( + arr.filter(isEven), + ); + + for (let i = 0; i < arr.length; i++) + expect(isEven).toHaveBeenNthCalledWith( + i + 1, + arr[i], + i, + arr, + ); + }); + + test("reduce()", () => { + const arr = [5, 5, 5, 5, 5] as const; + + const callback = vi.fn((a: number, b: number) => a + b); + const expected = arr.reduce(callback, 0); + + callback.mockClear(); + + expect($(arr).reduce(0, callback).value).toBe(expected); + + let sum = 0; + for (let i = 0; i < arr.length; i++) { + expect(callback).toHaveBeenNthCalledWith( + i + 1, + sum, + arr[i], + i, + arr, + ); + sum += arr[i]!; + } + + callback.mockClear(); + + expect($(arr).reduce.right(0, callback).value).toBe(expected); + + sum = 0; + for (let i = arr.length; i > 0; i--) { + expect(callback).toHaveBeenNthCalledWith( + arr.length - i + 1, + sum, + arr[i - 1], + i - 1, + arr, + ); + sum += arr[i - 1]!; + } + }); + + test("length()", () => { + const arr = [3, 3, 3] as const; + + expect($(arr).length().value).toBe(arr.length); + }); + + test("sort()", () => { + const numbers = [1, 5, 3, 4, 2] as const; + + const callback = (a: number) => (a % 2 === 0 ? -1 : 1); + + expect($(numbers).sort(callback).value).toMatchObject( + numbers.toSorted(callback), + ); + + expect($(numbers).sort.ascending().value).toMatchObject( + numbers.toSorted((a, b) => a - b), + ); + expect($(numbers).sort.descending().value).toMatchObject( + numbers.toSorted((a, b) => b - a), + ); + + const boxed = [{ v: 3 }, { v: 2 }, { v: 1 }]; + expect( + $(boxed).sort.ascending((v) => v.v).value, + ).toMatchObject(boxed.toSorted((a, b) => a.v - b.v)); + expect( + $(boxed).sort.descending((v) => v.v).value, + ).toMatchObject(boxed.toSorted((a, b) => b.v - a.v)); + + const strings = [ + "beta", + "alpha", + "bart", + "apple", + "charlie", + ] as const; + + expect($(strings).sort.alpha().value).toMatchObject( + strings.toSorted(), + ); + }); +} diff --git a/src/mixin/awaited.ts b/src/mixin/awaited.ts index a021bb5..c27ec16 100644 --- a/src/mixin/awaited.ts +++ b/src/mixin/awaited.ts @@ -1,47 +1,136 @@ import { makeFluent } from "../base"; import type { HKT } from "../base/hkt"; -import { - Mixin, - type Input, - type Props, - type Return, -} from "../base/mixin"; -import type { Empty } from "../internal"; -import type { Methods } from "../registry"; +import { Mixin, shim, type Input, type Return } from "../base/mixin"; +import { assert, assertType, never } from "../internal"; import { Base } from "./base"; +interface AwaitedIdentitity extends HKT { + new: (t: HKT.T) => Awaited>; +} + class Awaited> { public value: T; public constructor(value: T) { this.value = value; } + + public get [shim]() { + return never as { + input: T extends Promise ? U : never; + output: AwaitedIdentitity; + value: T; + }; + } } -export interface AwaitedMixin extends Mixin.HKT { +export interface AsyncMixin extends Mixin.HKT { new: (t: HKT.T) => Input extends infer T ? T extends Promise ? { readonly awaited: Return, typeof t>; + then: ( + callback: ( + value: T extends Promise + ? T + : never, + ) => U, + ) => Return, typeof t>; } - : 0 + : unknown : never; } -export const AwaitedMixin = Mixin( - (value, $, fluent) => {}, -); +interface BaseFluent { + value: T; + [K: PropertyKey]: unknown; +} + +export const AsyncMixin = Mixin((value, $, fluent) => { + if (!(value instanceof Promise)) return; + + $.then = (callback: (value: unknown) => unknown) => { + return fluent(value.then(callback)); + }; + + Object.defineProperty($, "awaited", { + enumerable: true, + get() { + let v: Promise> = value.then((v) => + fluent(v), + ); + + const path: PropertyKey[] = []; + // eslint-disable-next-line no-empty-pattern + const proxy = new Proxy((...[]: unknown[]) => proxy, { + get: (_, key) => { + if (key === "value") + return v.then((v) => v.value); + path.push(key); + return proxy; + }, + apply: (target, thisArg, args: unknown[]) => { + v = v.then((v) => { + let obj: unknown = v; + for (const node of path) { + assert( + typeof obj === "object" && + obj !== null && + node in obj, + ); + assertType>( + obj, + ); + + obj = obj[node]; + } + + assert(typeof obj === "function"); + assertType< + ( + ...args: unknown[] + ) => BaseFluent + >(obj); + + return obj(...args); + }); + return target.apply(thisArg, args); + }, + }); + + return proxy; + }, + }); +}); if (import.meta.vitest) { const { test, expect } = import.meta.vitest; - const registry = [Base, AwaitedMixin] as const; + const registry = [Base, AsyncMixin] as const; const $ = makeFluent(registry); - test(".awaited", () => { + test(".awaited", async () => { + const value = 10; const promise = new Promise((r) => { - r(10); + r(value); }); - const m = $(promise).V; + const increment = (n: number) => n + 1; + + const result = $(promise).awaited.transform(increment).value; + + expect(await result).toBe(increment(value)); + }); + + test("then()", async () => { + const value = 10; + const promise = new Promise((r) => { + r(value); + }); + + const increment = (n: number) => n + 1; + + const result = $(promise).then(increment).value; + + expect(await result).toBe(increment(value)); }); } diff --git a/src/mixin/base.ts b/src/mixin/base.ts index c8e1445..4ac329f 100644 --- a/src/mixin/base.ts +++ b/src/mixin/base.ts @@ -1,7 +1,6 @@ import { makeFluent } from "../base"; import type { HKT } from "../base/hkt"; import { Mixin, type Input, type Return } from "../base/mixin"; -import type { Empty } from "../internal"; export interface Base extends Mixin.HKT { new: (t: HKT.T) => Input extends infer T @@ -12,18 +11,17 @@ export interface Base extends Mixin.HKT { transform( callback: (value: T) => U, ): Return; - readonly V: Return; } : never; } export const Base = Mixin((value, $, fluent) => { - $.tap = (callback) => { + $.tap = (callback: (value: unknown) => void) => { callback(value); return fluent(value); }; - $.transform = (callback) => { + $.transform = (callback: (value: unknown) => unknown) => { return fluent(callback(value)); }; }); diff --git a/src/mixin/index.ts b/src/mixin/index.ts index 4534aa5..01b124e 100644 --- a/src/mixin/index.ts +++ b/src/mixin/index.ts @@ -1,2 +1,3 @@ export { Base } from "./base"; -export { AwaitedMixin as Awaited } from "./awaited"; +export { AsyncMixin as Async } from "./awaited"; +export { Optional } from "./optional"; diff --git a/src/mixin/optional.ts b/src/mixin/optional.ts new file mode 100644 index 0000000..64e9ad9 --- /dev/null +++ b/src/mixin/optional.ts @@ -0,0 +1,132 @@ +import { makeFluent } from "../base"; +import type { HKT } from "../base/hkt"; +import { + Mixin, + type Input, + type Props, + type Return, +} from "../base/mixin"; +import { assertType, type HidePrototype } from "../internal"; + +type NoneSentinel = null | undefined; +type None = Extract; +type Some = Exclude; + +function isNone(v: T): v is None { + return v === null || v === undefined; +} + +interface Where { + where: ( + callback: (v: T) => boolean, + fallback?: U, + ) => Return; +} + +export interface Optional extends Mixin.HKT { + new: (t: HKT.T) => Input extends infer T + ? None extends never + ? Where + : { + and: ( + callback: (v: Some) => U, + ) => Return | U, typeof t>; + or: (( + fallback: U, + ) => Return | U, typeof t>) & { + else: ( + callback: (v: None) => U, + ) => Return | U, typeof t>; + } & HidePrototype; + } & Where + : never; +} + +export const Optional = Mixin((value, $, fluent) => { + if (isNone(value)) { + $.and = () => { + return fluent(value); + }; + + $.or = (fallback: unknown) => { + return fluent(fallback); + }; + + assertType($.or); + Object.assign($.or, { + else: (callback: (v: unknown) => unknown) => { + return fluent(callback(value)); + }, + }); + } else { + $.and = (callback: (v: unknown) => unknown) => { + return fluent(callback(value)); + }; + + $.or = () => { + return fluent(value); + }; + + assertType($.or); + Object.assign($.or, { + else: () => { + return fluent(value); + }, + }); + } + + $.where = ( + callback: (v: unknown) => boolean, + fallback = null, + ) => { + if (callback(value)) return fluent(value); + return fluent(fallback); + }; +}); + +if (import.meta.vitest) { + const { test, expect } = import.meta.vitest; + + const registry = [Optional] as const; + const $ = makeFluent(registry); + + type Value = number | null; + + test("and()", () => { + const value = 10; + const some = value as Value; + const none = null as Value; + + const callback = (v: number) => v + 5; + + expect($(some).and(callback).value).toBe(callback(value)); + expect($(none).and(callback).value).toBe(null); + }); + + test("or()", () => { + const some = 10 as Value; + const none = null as Value; + + const fallback = 15 as const; + + expect($(some).or(fallback).value).toBe(some); + expect($(none).or(fallback).value).toBe(fallback); + + const callback = () => fallback; + + expect($(some).or.else(callback).value).toBe(some); + expect($(none).or.else(callback).value).toBe(fallback); + }); + + test("where()", () => { + const even = 4; + const odd = 7; + + const isEven = (v: number) => v % 2 === 0; + + expect($(even).where(isEven).value).toBe(even); + expect($(odd).where(isEven).value).toBe(null); + + expect($(odd).where(isEven, -1).value).toBe(-1); + }); +} diff --git a/src/registry.ts b/src/registry.ts index e9c2e96..140465e 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -1,26 +1,25 @@ import { Mixin, type Instansiate } from "./base/mixin"; -import type { Empty } from "./internal"; -import { Base, Awaited } from "./mixin"; +import { Async, Base, Optional } from "./mixin"; +import { Array } from "./mixin/array"; export type Registry = readonly Mixin[]; export const DEFAULT_REGISTRY = [ + Async, + Array, Base, - Awaited, + Optional, ] as const satisfies Registry; export type DefaultRegistry = typeof DEFAULT_REGISTRY; -type Expand = { [K in keyof T]: T[K] }; -type Merge = [T] extends [0] ? U : [U] extends [0] ? T : T & U; - export type Methods< T, Reg extends Registry, TReg extends Registry = Reg, - TOut = {}, + TOut extends Record = Record, > = TReg extends readonly [ infer M extends Mixin, ...infer Rest extends Registry, ] - ? Methods>> + ? Methods> : TOut;