feat: more mixins

This commit is contained in:
2026-06-23 17:04:03 +02:00
parent 3dc22e3f62
commit edd6a68d9d
10 changed files with 771 additions and 62 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
export interface HKT<In = unknown, Out = unknown> {
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<This, T>;
export type Apply<T extends HKT, t extends T["_t"]> = ReturnType<
(T & { _t: t })["new"]
+7 -4
View File
@@ -1,18 +1,21 @@
import type { DefaultRegistry, Methods, Registry } from "../registry";
import type { shim } from "./mixin";
export interface Base<T> {
export type Base<T> = T extends { [shim]: { value: infer U } }
? { readonly value: U }
: {
readonly value: T;
}
};
export type Fluent<
T,
Reg extends Registry = DefaultRegistry,
> = Base<T> & Methods<T, Reg> & { readonly __registry: Reg };
> = Base<T> & Methods<T, Reg>;
export function makeFluent<const Reg extends Registry>(
registry: Reg,
) {
const fluent = <T>(value: T) => {
const fluent = <const T>(value: T) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const f = { value } as never as Fluent<T, Reg>;
+20 -23
View File
@@ -8,30 +8,19 @@ export interface Props {
readonly meta: { registry: Registry };
}
type UnionToIntersection<U> = (
U extends unknown ? (x: U) => void : never
) extends (x: infer I) => void
? I
: never;
type MixinHKT = HKT<Props>;
type MixinFn<T extends MixinHKT> = (
type MixinFn = (
value: unknown,
fluent: Partial<
UnionToIntersection<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
HKT.Apply<T, { value: any; meta: { registry: [] } }>
>
>,
fluent: Record<PropertyKey, unknown>,
callback: (value: unknown) => never,
) => void;
export interface Mixin<T extends MixinHKT = MixinHKT> {
hkt: T;
fn: MixinFn<T>;
fn: MixinFn;
}
export function Mixin<T extends MixinHKT>(fn: MixinFn<T>): Mixin<T> {
export function Mixin<T extends MixinHKT>(fn: MixinFn): Mixin<T> {
return {
hkt: never,
fn,
@@ -40,18 +29,26 @@ export function Mixin<T extends MixinHKT>(fn: MixinFn<T>): Mixin<T> {
export namespace Mixin {
export type HKT = MixinHKT;
export type Function<T extends MixinHKT> = MixinFn<T>;
export type Function = MixinFn;
}
// type Fluent<T, P extends Props> = _Fluent<
// T,
// P["meta"]["registry"]
// > & {};
export declare const shim: unique symbol;
export interface Shim {
input?: unknown;
output?: HKT;
value?: unknown;
}
export type Input<P extends Props> = P["value"];
export type Input<P extends Props> = P["value"] extends infer T
? T extends { [shim]: { input: infer I } }
? I
: T
: never;
export type Return<T, P extends Props> = Fluent<
T,
P extends { meta: { registry: infer Reg } } ? Reg : never
P["value"] extends { [shim]: { output: infer U extends HKT } }
? HKT.Apply<U, T>
: T,
P["meta"]["registry"]
>;
export type Instansiate<
+48 -2
View File
@@ -9,5 +9,51 @@ export const never = undefined as never;
export type Constraint<T, U extends T> = U;
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export type Empty = Constraint<Record<PropertyKey, unknown>, {}>;
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<T>(_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;
}
+444
View File
@@ -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<T extends readonly unknown[] = unknown[]> = [
element: T[number],
idx: number,
array: Readonly<T>,
];
type MaxDepth = 20;
type Unordered<T extends readonly unknown[]> = 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<TRest, U, [...TOut, U]>
: 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<TRest, [...TOut, T]>
: T extends readonly []
? TOut
: T[number][];
namespace At {
export type Dec<N extends number> = [
-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<TRest, Dec<Skip>>
: undefined;
}
type At<
T extends readonly unknown[],
K extends keyof T & number,
> = `${K}` extends `-${infer N extends number}`
? At.Select<T, At.Dec<N>>
: T[K];
namespace Flat {
type Inc<
N extends number,
Acc extends unknown[] = [],
> = Acc["length"] extends N
? [...Acc, null]["length"]
: Inc<N, [...Acc, null]>;
export type Leaves<
T,
Depth extends number = 0,
> = Depth extends MaxDepth
? T
: T extends readonly unknown[]
? Leaves<T[number], Inc<Depth>>
: 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<H, Inc<Depth>>, ...Rebuild<R, Depth>]
: [H, ...Rebuild<R, Depth>]
: [];
}
export type Flat<T extends readonly unknown[]> =
number extends T["length"] ? Flat.Leaves<T>[] : Flat.Rebuild<T>;
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<this>) => Input<typeof t> extends infer T
? T extends readonly (infer Item)[]
? {
at: <const K extends keyof T & number>(
index: K,
) => Return<At<T, K>, typeof t>;
each: (
callback: (...args: IterArgs<T>) => void,
) => Return<T, typeof t>;
map: <U>(
callback: (...args: IterArgs<T>) => U,
) => Return<Map<T, U>, typeof t>;
filter: (
callback: (...args: IterArgs<T>) => boolean,
) => Return<Unordered<T>, typeof t>;
reduce: (<U>(
initial: U,
callback: (
value: U,
...args: IterArgs<T>
) => U,
) => Return<U, typeof t>) & {
right: <U>(
initial: U,
callback: (
value: U,
...args: IterArgs<T>
) => U,
) => Return<U, typeof t>;
} & HidePrototype;
length: () => Return<T["length"], typeof t>;
sort: ((
callback: (
a: Item,
b: Item,
arr: Readonly<T>,
) => number,
) => Return<Unordered<T>, typeof t>) &
([Item] extends [string]
? {
alpha: () => Return<
Unordered<T>,
typeof t
>;
}
: {
alpha: (
via: (v: Item) => string,
) => Return<
Unordered<T>,
typeof t
>;
}) &
([Item] extends [number]
? {
ascending: () => Return<
Unordered<T>,
typeof t
>;
descending: () => Return<
Unordered<T>,
typeof t
>;
}
: {
ascending: (
via: (v: Item) => number,
) => Return<
Unordered<T>,
typeof t
>;
descending: (
via: (v: Item) => number,
) => Return<
Unordered<T>,
typeof t
>;
}) &
HidePrototype;
reverse: () => Return<Reverse<T>, typeof t>;
}
: unknown
: never;
}
export const Array = Mixin<Array>((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<object>($.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<object>($.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(),
);
});
}
+106 -17
View File
@@ -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<this>) => Awaited<Promise<typeof t>>;
}
class Awaited<T extends Promise<unknown>> {
public value: T;
public constructor(value: T) {
this.value = value;
}
public get [shim]() {
return never as {
input: T extends Promise<infer U> ? U : never;
output: AwaitedIdentitity;
value: T;
};
}
}
export interface AwaitedMixin extends Mixin.HKT {
export interface AsyncMixin extends Mixin.HKT {
new: (t: HKT.T<this>) => Input<typeof t> extends infer T
? T extends Promise<unknown>
? {
readonly awaited: Return<Awaited<T>, typeof t>;
then: <U>(
callback: (
value: T extends Promise<infer T>
? T
: never,
) => U,
) => Return<Promise<U>, typeof t>;
}
: 0
: unknown
: never;
}
export const AwaitedMixin = Mixin<AwaitedMixin>(
(value, $, fluent) => {},
);
interface BaseFluent<T> {
value: T;
[K: PropertyKey]: unknown;
}
export const AsyncMixin = Mixin<AsyncMixin>((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<BaseFluent<unknown>> = 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<Record<PropertyKey, unknown>>(
obj,
);
obj = obj[node];
}
assert(typeof obj === "function");
assertType<
(
...args: unknown[]
) => BaseFluent<unknown>
>(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<number>((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<number>((r) => {
r(value);
});
const increment = (n: number) => n + 1;
const result = $(promise).then(increment).value;
expect(await result).toBe(increment(value));
});
}
+2 -4
View File
@@ -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<this>) => Input<typeof t> extends infer T
@@ -12,18 +11,17 @@ export interface Base extends Mixin.HKT {
transform<U>(
callback: (value: T) => U,
): Return<U, typeof t>;
readonly V: Return<number, typeof t>;
}
: never;
}
export const Base = Mixin<Base>((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));
};
});
+2 -1
View File
@@ -1,2 +1,3 @@
export { Base } from "./base";
export { AwaitedMixin as Awaited } from "./awaited";
export { AsyncMixin as Async } from "./awaited";
export { Optional } from "./optional";
+132
View File
@@ -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<T> = Extract<T, NoneSentinel>;
type Some<T> = Exclude<T, NoneSentinel>;
function isNone<T>(v: T): v is None<T> {
return v === null || v === undefined;
}
interface Where<T, t extends Props> {
where: <U = null>(
callback: (v: T) => boolean,
fallback?: U,
) => Return<T | U, t>;
}
export interface Optional extends Mixin.HKT {
new: (t: HKT.T<this>) => Input<typeof t> extends infer T
? None<T> extends never
? Where<T, typeof t>
: {
and: <U>(
callback: (v: Some<T>) => U,
) => Return<None<T> | U, typeof t>;
or: (<U>(
fallback: U,
) => Return<Some<T> | U, typeof t>) & {
else: <U>(
callback: (v: None<T>) => U,
) => Return<Some<T> | U, typeof t>;
} & HidePrototype;
} & Where<T, typeof t>
: never;
}
export const Optional = Mixin<Optional>((value, $, fluent) => {
if (isNone(value)) {
$.and = () => {
return fluent(value);
};
$.or = (fallback: unknown) => {
return fluent(fallback);
};
assertType<object>($.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<object>($.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);
});
}
+7 -8
View File
@@ -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<T> = { [K in keyof T]: T[K] };
type Merge<T, U> = [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<PropertyKey, unknown> = Record<never, never>,
> = TReg extends readonly [
infer M extends Mixin,
...infer Rest extends Registry,
]
? Methods<T, Reg, Rest, Merge<TOut, Instansiate<M, T, Reg>>>
? Methods<T, Reg, Rest, TOut & Instansiate<M, T, Reg>>
: TOut;