Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 109 additions & 46 deletions async/unstable_lazy.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,32 @@
// Copyright 2018-2026 the Deno authors. MIT license.
// This module is browser compatible.

/**
* Options for {@linkcode Lazy.prototype.get}.
*
* @experimental **UNSTABLE**: New API, yet to be vetted.
*/
export interface LazyGetOptions {
/**
* Signal used to abort the wait for initialization.
*
* Aborting does not cancel the underlying initializer — it only rejects the
* caller's promise. Other callers and any in-flight initialization are
* unaffected.
*/
signal?: AbortSignal;
}

/**
* A lazy value that is initialized at most once, with built-in deduplication of
* concurrent callers. Prevents the common race where two concurrent `get()` calls
* both trigger the initializer; only one initialization runs and all callers share
* the same promise.
*
* @experimental **UNSTABLE**: New API, yet to be vetted.
* If the initializer rejects, the error is propagated to all concurrent callers
* and the internal state is cleared — the next {@linkcode Lazy.prototype.get}
* call will re-run the initializer. Compose with {@linkcode retry} for
* automatic back-off on transient failures.
*
* @example Concurrent deduplication
*
Expand Down Expand Up @@ -39,6 +58,8 @@
* await db.get();
* ```
*
* @experimental **UNSTABLE**: New API, yet to be vetted.
*
* @typeParam T The type of the lazily initialized value.
*/
export class Lazy<T> {
Expand All @@ -65,8 +86,6 @@ export class Lazy<T> {
*
* Always returns a promise, even when the initializer is synchronous.
*
* @experimental **UNSTABLE**: New API, yet to be vetted.
*
* @example Usage
* ```ts no-assert
* import { Lazy } from "@std/async/unstable-lazy";
Expand All @@ -75,37 +94,71 @@ export class Lazy<T> {
* const value = await config.get();
* ```
*
* @example Abort a slow initialization
* ```ts
* import { Lazy } from "@std/async/unstable-lazy";
* import { assertRejects } from "@std/assert";
*
* const slow = new Lazy(() => new Promise<string>(() => {}));
* const controller = new AbortController();
* controller.abort(new Error("timed out"));
* await assertRejects(
* () => slow.get({ signal: controller.signal }),
* Error,
* "timed out",
* );
* ```
*
* @experimental **UNSTABLE**: New API, yet to be vetted.
*
* @param options Optional settings for this call.
* @returns The cached or newly initialized value.
*/
get(): Promise<T> {
if (this.#promise !== undefined) {
return this.#promise;
get(options?: LazyGetOptions): Promise<T> {
const signal = options?.signal;
if (signal?.aborted) return Promise.reject(signal.reason);

if (this.#promise === undefined) {
const p = new Promise<T>((resolve, reject) => {
Promise.resolve().then(() => this.#init()).then(
(value) => {
if (this.#promise === p) {
this.#value = value;
this.#settled = true;
}
resolve(value);
},
(err) => {
if (this.#promise === p) {
this.#promise = undefined;
}
reject(err);
},
);
});
this.#promise = p;
}
const p = Promise.resolve().then(() => this.#init());
this.#promise = p;
p.then(
(value) => {
if (this.#promise === p) {
this.#value = value;
this.#settled = true;
}
return value;
},
(_err) => {
if (this.#promise === p) {
this.#promise = undefined;
}
},
);
return p;

if (!signal) return this.#promise;

return new Promise<T>((resolve, reject) => {
const abort = () => reject(signal.reason);
signal.addEventListener("abort", abort, { once: true });
this.#promise!.then(
(value) => {
signal.removeEventListener("abort", abort);
resolve(value);
},
(err) => {
signal.removeEventListener("abort", abort);
reject(err);
},
);
});
}

/**
* Whether the value has been successfully initialized. Useful for
* distinguishing "not yet initialized" from "initialized with `undefined`"
* when `T` can be `undefined`.
*
* @experimental **UNSTABLE**: New API, yet to be vetted.
* Whether the value has been successfully initialized.
*
* @example Check initialization state
* ```ts
Expand All @@ -118,48 +171,56 @@ export class Lazy<T> {
* assertEquals(lazy.initialized, true);
* ```
*
* @experimental **UNSTABLE**: New API, yet to be vetted.
*
* @returns `true` if the value has been initialized, `false` otherwise.
*/
get initialized(): boolean {
return this.#settled;
}

/**
* Returns the value if already resolved, `undefined` otherwise. Useful for
* fast-path checks where you do not want to await. Returns `undefined` while
* initialization is in-flight.
*
* If `T` can be `undefined`, use {@linkcode initialized} to distinguish
* "not yet initialized" from "initialized with `undefined`".
*
* @experimental **UNSTABLE**: New API, yet to be vetted.
* Returns the value if already resolved, or indicates that it is not yet
* available. The discriminated union avoids ambiguity when `T` itself can
* be `undefined`.
*
* @example Fast-path when already initialized
* ```ts no-assert
* ```ts
* import { Lazy } from "@std/async/unstable-lazy";
* import { assertEquals } from "@std/assert";
*
* const config = new Lazy(async () => ({ port: 8080 }));
* await config.get();
*
* const cached = config.peek();
* if (cached !== undefined) {
* console.log("using cached", cached.port);
* }
* const result = config.peek();
* assertEquals(result, { ok: true, value: { port: 8080 } });
* ```
*
* @returns The resolved value, or `undefined` if not yet initialized or still in-flight.
* @example Not yet initialized
* ```ts
* import { Lazy } from "@std/async/unstable-lazy";
* import { assertEquals } from "@std/assert";
*
* const lazy = new Lazy(() => 42);
* assertEquals(lazy.peek(), { ok: false });
* ```
*
* @experimental **UNSTABLE**: New API, yet to be vetted.
*
* @returns `{ ok: true, value }` if the value has been initialized, or
* `{ ok: false }` if not yet initialized or still in-flight.
*/
peek(): T | undefined {
return this.#settled ? this.#value : undefined;
peek(): { ok: true; value: T } | { ok: false } {
return this.#settled
? { ok: true, value: this.#value as T }
: { ok: false };
}

/**
* Resets the lazy so the next {@linkcode get} re-runs the initializer. Does
* not cancel an in-flight initialization; callers that already have the
* promise will still receive its result.
*
* @experimental **UNSTABLE**: New API, yet to be vetted.
*
* @example Force reload
* ```ts ignore
* import { Lazy } from "@std/async/unstable-lazy";
Expand All @@ -169,6 +230,8 @@ export class Lazy<T> {
* config.reset();
* const fresh = await config.get();
* ```
*
* @experimental **UNSTABLE**: New API, yet to be vetted.
*/
reset(): void {
this.#promise = undefined;
Expand Down
Loading
Loading