doddle
Version:
Tiny yet feature-packed (async) iteration toolkit.
526 lines (497 loc) • 17.6 kB
text/typescript
import { chk, DoddleError, loadCheckers } from "../errors/error.js"
import {
getClassName,
getValueDesc,
isDoddle,
isFunction,
isThenable,
type MaybeDoddleAsync,
type MaybePromise
} from "../utils.js"
import type { Is_Any_Mixed, Is_Any_Pure_Async, Matches_Mixed_Value } from "./helpers.js"
export const ownerInstance = Symbol("ownerInstance")
/**
* A TypeScript-first doddle evaluation primitive. An object that will only evaluate its initializer
* function when the {@link pull} method is called.
*
* The initializer can return another {@link Doddle}, which will be chained like a promise.
*
* @category Use
*/
export class Doddle<T> {
private _cacheName!: string
/** The cached value or error, stored from a previous execution of the initializer. */
private _cached?: any
private _info: InnerInfo
/**
* The initializer function that will be called to construct the value. It will be cleared after
* the value is constructed.
*/
private _init: null | ((...args: any[]) => T)
/** @ignore */
constructor(initializer: (...args: any[]) => any) {
this._info = {
syncness: Syncness.Untouched,
stage: Stage.Untouched
}
this._init = initializer
for (const name of ["map", "do", "zip", "catch", "pull"] as const) {
const bound = (this as any)[name].bind(this)
bound[ownerInstance] = this
this[name] = bound
}
loadCheckers(this)
}
/** @internal */
get [Symbol.toStringTag]() {
return "Doddle"
}
/** Returns metadata about the current state of the Doddle. */
get info(): Readonly<Doddle.Metadata> {
const { stage, syncness } = this._info
const syncnessWord = ["untouched", "sync", "async"][syncness]
const syncnessPart = syncness === Syncness.Untouched ? [] : [syncnessWord]
const stageWord = ["untouched", "executing", "done", "threw"][stage]
const stagePart = stage === Stage.Done ? this._cacheName : `<${stageWord}>`
return {
isReady: stage >= Stage.Done,
desc: ["doddle", ...syncnessPart, stagePart].join(" "),
stage: stageWord,
syncness: syncnessWord
}
}
/**
* Returns a new {@link Doddle} based on this one. When pulled, it will pull `this` and yield the
* same value. If an error occurs, the handler will be called with the error. The doddle then
* yields whatever the handler returns.
*
* You can pass an async handler only if `this` is async too.
*
* @example
* const d = doddle(() => {
* throw new Error("Oops")
* })
* const handled = d.catch(error => {
* console.error(error)
* return 42
* })
* const pulled = handled.pull()
* console.log(pulled) // 42
*
* // async Doddles allow an async handler:
* const d = doddle(async () => {
* throw new Error("Oops")
* })
* const handled = d.catch(async error => {
* console.error(error)
* return 42
* })
* const pulled = await handled.pull()
* console.log(pulled) // 42
*
* // @ts-expect-error but passing an async handler for a sync doddle isn't allowed.
* const d = doddle(() => 1).catch(async () => 2)
*
* @param this The Doddle instance.
* @param handler The error handler function.
*/
catch<T, R>(
this: DoddleAsync<T>,
handler: (error: any) => Doddle.SomeAsync<R>
): DoddleAsync<T | Awaited<R>>
catch<T, R>(
this: DoddleAsync<T>,
handler: (error: any) => R | Doddle<R> | Doddle.SomeAsync<R>
): DoddleAsync<T | Awaited<R>>
catch<T, R>(
this: Matches_Mixed_Value<T>,
handler: (error: any) => Doddle.SomeAsync<R>
): Doddle<T | Promise<R>>
catch<T, R>(this: Matches_Mixed_Value<T>, handler: (error: any) => R | Doddle<R>): Doddle<T | R>
catch<R>(
handler: R extends PromiseLike<any> ? never : (error: any) => R | Doddle<R>
): Doddle<R | T>
catch(handler: (error: any) => any): any {
chk(this.catch).handler(handler)
return doddle(() => {
try {
const pulled = this.pull()
if (isThenable(pulled)) {
return pulled.then(undefined, handler)
}
return pulled
} catch (e) {
return handler(e)
}
})
}
/**
* Returns a new {@link Doddle} based on this one. When pulled, it will pull `this` and invoke
* the given action function as a side-effect. It will then yield whatever `this` did.
*
* If the action returns a Promise, it will be awaited before yielding the result, making the
* returned Doddle async.
*
* @param action The action to perform.
*/
do<T>(
this: Matches_Mixed_Value<T>,
action: (value: Doddle.PulledAwaited<T>) => Doddle.SomeAsync<void>
): DoddleAsync<Awaited<T>>
// async.do(anything) = async
do<T>(
this: DoddleAsync<T>,
action: (value: Doddle.PulledAwaited<T>) => void | Doddle<void> | Doddle.SomeAsync<void>
): DoddleAsync<T>
// mixed.do(mixed | sync) = mixed
do<T>(
this: Matches_Mixed_Value<T>,
action: (value: Doddle.PulledAwaited<T>) => void | Doddle<void> | Doddle.SomeAsync<void>
): Doddle<T>
// sync.do(async) = async
do<T>(
this: Doddle<T>,
action: (value: Doddle.PulledAwaited<T>) => Doddle.SomeAsync<void>
): DoddleAsync<T>
// sync.do(mixed) = mixed
do<T, R>(
this: Matches_Mixed_Value<R, Doddle<T>>,
action: (value: Doddle.PulledAwaited<T>) => R | Doddle<R>
): Doddle<T | Promise<T>>
// sync.do(sync) = sync
do<T>(
this: Doddle<T>,
action: (value: Doddle.PulledAwaited<T>) => void | Doddle<void>
): Doddle<T>
do(this: any, action: (value: any) => any): any {
chk(this.do).action(action)
return this.map((x: any) => {
const result = action(x)
return doddle(() => {
return result
}).map(() => x)
})
}
/**
* Creates a new {@link Doddle} based on `this`. When pulled, it will pull `this` and project the
* result using the given function.
*
* When `this` is async, the projection will be passed the awaited value, and the function will
* return an async Doddle. It also happens if you pass an async projection.
*
* @example
* // Sync inputs:
* const d = doddle(() => 42)
* const mapped = d.map(x => x + 1)
* const pulled = mapped.pull()
* console.log(pulled) // 43
*
* // async inputs:
* const d = doddle(async () => 42)
* const mapped = d.map(x => x + 1) // note that the awaited value is used
* const pulled = await mapped.pull()
* console.log(pulled) // 43
*
* // async projection:
* const d = doddle(() => 42)
* const mapped = d.map(async x => x + 1)
* const pulled = await mapped.pull()
* console.log(pulled) // 43
*
* @param projection The function to apply to the pulled value.
*/
map<T, R>(
this: Doddle<T>,
projection: (value: Doddle.PulledAwaited<T>) => Doddle.SomeAsync<R>
): DoddleAsync<R>
// When the input is async, and the projection is mixed, the result is always async.
map<T, R>(
this: Matches_Mixed_Value<R, DoddleAsync<T>>,
projection: (value: Doddle.PulledAwaited<T>) => R | Doddle<R>
): DoddleAsync<Awaited<R>>
map<T, R>(
this: DoddleAsync<T>,
projection: (value: Doddle.PulledAwaited<T>) => Doddle<R> | R
): DoddleAsync<R>
// When this is mixed, and the projection is also mixed, the result type should stay the same.
map<T, R>(
this: Matches_Mixed_Value<T> & Matches_Mixed_Value<R, Doddle<T>>,
projection: (value: Doddle.PulledAwaited<T>) => R | Doddle<R>
): Doddle<R>
// When `this` is mixed and the projection is sync, the sync result needs to be mixed.
map<T, R>(
this: Matches_Mixed_Value<T>,
projection: (value: Doddle.PulledAwaited<T>) => R | Doddle<R>
): Doddle<R | Promise<R>>
map<T, R>(
this: Doddle<T>,
projection: (value: Doddle.PulledAwaited<T>) => R | Doddle<R>
): Doddle<R>
map(this: Doddle<any>, projection: (a: any) => any): any {
const _projection = chk(this.map).projection(projection)
return doddle(() => {
const pulled = this.pull()
if (isThenable(pulled)) {
return pulled.then(_projection)
}
return _projection(pulled)
})
}
/**
* Returns a memoized function, which acts like this Doddle while hiding its type.
*
* @returns A memoized function that pulls `this` and returns its result.
*/
memoize(): () => T {
return this.pull as any
}
/**
* Evaluates this {@link Doddle} instance, flattening any nested {@link Doddle} or {@link Promise}
* types and yielding its value.
*
* @returns The yielded value.
* @throws The error thrown during initialization, if any.
*/
pull(): Doddle.Pulled<T> {
const info = this._info
if (info.stage === Stage.Executing) {
if (info.syncness === Syncness.Async) {
return this._cached
} else {
throw new DoddleError(
`Tried to call 'Doddle.pull' recursively in a sync context, which would not terminate.`
)
}
}
if (info.stage === Stage.Done) {
return this._cached!
}
info.stage = Stage.Executing
let resource: any
try {
const result = this._init!()
resource = isDoddle(result) ? result.pull() : result
} finally {
if (!resource) {
info.stage = Stage.Threw
}
}
// No need to keep holding a reference to the constructor.
this._init = null
if (isThenable(resource)) {
info.syncness = Syncness.Async
resource = resource.then(value => {
if (isDoddle(value)) {
value = value.pull()
}
info.stage = Stage.Done
this._cacheName = getClassName(value)
return value
})
} else {
info.syncness = Syncness.Sync
info.stage = Stage.Done
this._cacheName = getClassName(resource)
}
this._cached = resource
return resource
}
/** Returns a short description of the Doddle value and its state. */
toString() {
return this.info.desc
}
/**
* Returns a new Doddle based on this one, together with the input Doddles. When pulled, it will
* pull `this` and all `others`, yielding their results in an array.
*
* If either `this` or any of `others` is async, the resulting Doddle will also be async.
*
* @param others The other Doddles to zip with.
*/
zip<const Others extends readonly [Doddle<any>, ...Doddle<any>[]]>(
...others: Others
): Is_Any_Pure_Async<
[Doddle<T>, ...Others],
DoddleAsync<
[
Doddle.PulledAwaited<T>,
...{
[K in keyof Others]: Doddle.PulledAwaited<Others[K]>
}
]
>,
Is_Any_Mixed<
[Doddle<T>, ...Others],
Doddle<
MaybePromise<
[
Doddle.PulledAwaited<T>,
...{
[K in keyof Others]: Doddle.PulledAwaited<Others[K]>
}
]
>
>,
Doddle<
[
Doddle.Pulled<T>,
...{
[K in keyof Others]: Doddle.Pulled<Others[K]>
}
]
>
>
>
zip(this: Doddle<any>, ...others: Doddle<any>[]): any {
return doddle(() => {
const values = [this, ...others].map(x => x.pull())
if (values.some(isThenable)) {
return Promise.all(values)
}
return values
})
}
}
/**
* Creates a {@link Doddle} lazy primitive around a given function. Supports both sync and async
* initializers and flattens nested Doddle or {@link Promise} types.
*
* See examples for usage.
*
* @category Create
* @example
* // Simple initializer:
* const regular = doddle(() => 1) satisfies Doddle<number>
*
* // Initializer returning another lazily primitive is flattened:
* const lazyNested = doddle(() => doddle(() => 1)) satisfies Doddle<number>
*
* // Async initializer gives a `DoddleAsync` instance:
* const lazyAsync = doddle(async () => 1) satisfies DoddleAsync<number>
*
* // Async initializer returning another lazily primitive is flattened:
* const asyncDoddle = doddle(async () => doddle(() => 1)) satisfies DoddleAsync<number>
*
* // Async initializer returning another lazily async primitive is flattened:
* const asyncDoddleAsync = doddle(async () =>
* doddle(async () => 1)
* ) satisfies DoddleAsync<number>
*
* @param initializer An initializer that will be called once to produce the value.
*/
export function doddle<X>(initializer: () => PromiseLike<DoddleAsync<X>>): DoddleAsync<X>
export function doddle<X>(initializer: () => PromiseLike<Doddle<X>>): DoddleAsync<X>
export function doddle<X>(initializer: () => PromiseLike<X>): DoddleAsync<X>
export function doddle<T>(initializer: () => Doddle<T>): Doddle<T>
export function doddle<T>(initializer: () => T | Doddle<T>): Doddle<T>
export function doddle<T>(initializer: () => T | Doddle<T>): Doddle<T> {
if (!isFunction(initializer)) {
throw new Error(`Initializer must be a function, but got ${getValueDesc(initializer)}`)
}
if (ownerInstance in initializer) {
return initializer[ownerInstance] as any
}
return new Doddle(initializer) as any
}
/**
* Doddle utility functions.
*
* @category Create
*/
export namespace doddle {
export const is = isDoddle
}
const enum Stage {
Untouched = 0,
Executing = 1,
Done = 2,
Threw = 3
}
const enum Syncness {
Untouched = 0,
Sync = 1,
Async = 2
}
interface InnerInfo {
stage: Stage
syncness: Syncness
}
/**
* Doddle utility types.
*
* @category Types
*/
export namespace Doddle {
/** An metadata object describing the state of a {@link Doddle} instance. */
export interface Metadata {
/** A human-readable representation of the Doddle's state. */
readonly desc: string
/** Whether the Doddle has already been pulled. */
readonly isReady: boolean
/** The current stage of the Doddle's execution. */
readonly stage: string
/** Whether the Doddle is sync or async (or whether it's unknown). */
readonly syncness: string
}
/**
* Recursively pulls the result type of a {@link Doddle}, cutting thoruhg any {@link PromiseLike}
* types. Returns an async representation if the input as async.
*/
export type Pulled<T> =
T extends PromiseLike<infer X>
? Promise<PulledAwaited<X>>
: T extends Doddle<infer X>
? Pulled<X>
: T
/** Recursively pulls the result type of a {@link Doddle}, cutting through any {@link PromiseLike} */
export type PulledAwaited<T> =
T extends Doddle<infer R>
? PulledAwaited<R>
: T extends PromiseLike<infer R>
? PulledAwaited<R>
: T
/** An async value or a {@link Doddle} that can be pulled to get an async value. */
export type SomeAsync<T> =
| PromiseLike<T>
| DoddleAsync<T>
| PromiseLike<Doddle<T>>
| PromiseLike<DoddleAsync<T>>
/** A value, a promise, a doddle, an async doddle, or similar nestings. */
export type MaybePromised<T> = MaybePromise<DoddleAsync<T> | MaybeDoddleAsync<T>>
}
/**
* An async {@link Doddle}, which is just a `Doddle<Promise<T>>`.
*
* @category Use
*/
export type DoddleAsync<T> = Doddle<Promise<T>>
/** @internal */
export function lazyOperator<In, Out>(
operand: In,
func: (input: In) => Out | Doddle.Pulled<Out>
): Doddle<Out> {
const lz = doddle(() => func.call(operand, operand)) as any
Object.assign(lz, {
operator: func.name,
operand
})
return lz
}
/**
* Similar to `await`. Pulls a value from a {@link Doddle}, which may be async. The same as calling
* {@link Doddle.pull} on the input.
*
* @category Use
* @param input
*/
export function pull<T>(input: 1 extends 0 & T ? T : never): any
/**
* Similar to `await`. Pulls a value from a {@link Doddle}, which may be async. The same as calling
* {@link Doddle.pull} on the input.
*
* @param input
*/
export function pull<T>(input: T): Doddle.Pulled<T>
export function pull<T>(input: Doddle<T> | T): Doddle.Pulled<T> {
return doddle(() => input).pull()
}