Skip to content

Commit a3a6ef6

Browse files
BREAKING(async/unstable): change Lazy.peek() return type, add AbortSignal support (#7084)
1 parent 5f6936e commit a3a6ef6

2 files changed

Lines changed: 244 additions & 61 deletions

File tree

async/unstable_lazy.ts

Lines changed: 110 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,32 @@
11
// Copyright 2018-2026 the Deno authors. MIT license.
22
// This module is browser compatible.
33

4+
/**
5+
* Options for {@linkcode Lazy.prototype.get}.
6+
*
7+
* @experimental **UNSTABLE**: New API, yet to be vetted.
8+
*/
9+
export interface LazyGetOptions {
10+
/**
11+
* Signal used to abort the wait for initialization.
12+
*
13+
* Aborting does not cancel the underlying initializer — it only rejects the
14+
* caller's promise. Other callers and any in-flight initialization are
15+
* unaffected.
16+
*/
17+
signal?: AbortSignal;
18+
}
19+
420
/**
521
* A lazy value that is initialized at most once, with built-in deduplication of
622
* concurrent callers. Prevents the common race where two concurrent `get()` calls
723
* both trigger the initializer; only one initialization runs and all callers share
824
* the same promise.
925
*
10-
* @experimental **UNSTABLE**: New API, yet to be vetted.
26+
* If the initializer rejects, the error is propagated to all concurrent callers
27+
* and the internal state is cleared — the next {@linkcode Lazy.prototype.get}
28+
* call will re-run the initializer. Compose with {@linkcode retry} for
29+
* automatic back-off on transient failures.
1130
*
1231
* @example Concurrent deduplication
1332
*
@@ -39,6 +58,8 @@
3958
* await db.get();
4059
* ```
4160
*
61+
* @experimental **UNSTABLE**: New API, yet to be vetted.
62+
*
4263
* @typeParam T The type of the lazily initialized value.
4364
*/
4465
export class Lazy<T> {
@@ -65,8 +86,6 @@ export class Lazy<T> {
6586
*
6687
* Always returns a promise, even when the initializer is synchronous.
6788
*
68-
* @experimental **UNSTABLE**: New API, yet to be vetted.
69-
*
7089
* @example Usage
7190
* ```ts no-assert
7291
* import { Lazy } from "@std/async/unstable-lazy";
@@ -75,37 +94,72 @@ export class Lazy<T> {
7594
* const value = await config.get();
7695
* ```
7796
*
97+
* @example Abort a slow initialization
98+
* ```ts
99+
* import { Lazy } from "@std/async/unstable-lazy";
100+
* import { assertRejects } from "@std/assert";
101+
*
102+
* const slow = new Lazy(() => new Promise<string>(() => {}));
103+
* const controller = new AbortController();
104+
* controller.abort(new Error("timed out"));
105+
* await assertRejects(
106+
* () => slow.get({ signal: controller.signal }),
107+
* Error,
108+
* "timed out",
109+
* );
110+
* ```
111+
*
112+
* @experimental **UNSTABLE**: New API, yet to be vetted.
113+
*
114+
* @param options Optional settings for this call.
78115
* @returns The cached or newly initialized value.
79116
*/
80-
get(): Promise<T> {
81-
if (this.#promise !== undefined) {
82-
return this.#promise;
117+
get(options?: LazyGetOptions): Promise<T> {
118+
if (this.#settled) return Promise.resolve(this.#value as T);
119+
const signal = options?.signal;
120+
if (signal?.aborted) return Promise.reject(signal.reason);
121+
122+
if (this.#promise === undefined) {
123+
const p = new Promise<T>((resolve, reject) => {
124+
Promise.resolve().then(() => this.#init()).then(
125+
(value) => {
126+
if (this.#promise === p) {
127+
this.#value = value;
128+
this.#settled = true;
129+
}
130+
resolve(value);
131+
},
132+
(err) => {
133+
if (this.#promise === p) {
134+
this.#promise = undefined;
135+
}
136+
reject(err);
137+
},
138+
);
139+
});
140+
this.#promise = p;
83141
}
84-
const p = Promise.resolve().then(() => this.#init());
85-
this.#promise = p;
86-
p.then(
87-
(value) => {
88-
if (this.#promise === p) {
89-
this.#value = value;
90-
this.#settled = true;
91-
}
92-
return value;
93-
},
94-
(_err) => {
95-
if (this.#promise === p) {
96-
this.#promise = undefined;
97-
}
98-
},
99-
);
100-
return p;
142+
143+
if (!signal) return this.#promise;
144+
145+
return new Promise<T>((resolve, reject) => {
146+
const abort = () => reject(signal.reason);
147+
signal.addEventListener("abort", abort, { once: true });
148+
this.#promise!.then(
149+
(value) => {
150+
signal.removeEventListener("abort", abort);
151+
resolve(value);
152+
},
153+
(err) => {
154+
signal.removeEventListener("abort", abort);
155+
reject(err);
156+
},
157+
);
158+
});
101159
}
102160

103161
/**
104-
* Whether the value has been successfully initialized. Useful for
105-
* distinguishing "not yet initialized" from "initialized with `undefined`"
106-
* when `T` can be `undefined`.
107-
*
108-
* @experimental **UNSTABLE**: New API, yet to be vetted.
162+
* Whether the value has been successfully initialized.
109163
*
110164
* @example Check initialization state
111165
* ```ts
@@ -118,48 +172,56 @@ export class Lazy<T> {
118172
* assertEquals(lazy.initialized, true);
119173
* ```
120174
*
175+
* @experimental **UNSTABLE**: New API, yet to be vetted.
176+
*
121177
* @returns `true` if the value has been initialized, `false` otherwise.
122178
*/
123179
get initialized(): boolean {
124180
return this.#settled;
125181
}
126182

127183
/**
128-
* Returns the value if already resolved, `undefined` otherwise. Useful for
129-
* fast-path checks where you do not want to await. Returns `undefined` while
130-
* initialization is in-flight.
131-
*
132-
* If `T` can be `undefined`, use {@linkcode initialized} to distinguish
133-
* "not yet initialized" from "initialized with `undefined`".
134-
*
135-
* @experimental **UNSTABLE**: New API, yet to be vetted.
184+
* Returns the value if already resolved, or indicates that it is not yet
185+
* available. The discriminated union avoids ambiguity when `T` itself can
186+
* be `undefined`.
136187
*
137188
* @example Fast-path when already initialized
138-
* ```ts no-assert
189+
* ```ts
139190
* import { Lazy } from "@std/async/unstable-lazy";
191+
* import { assertEquals } from "@std/assert";
140192
*
141193
* const config = new Lazy(async () => ({ port: 8080 }));
142194
* await config.get();
143195
*
144-
* const cached = config.peek();
145-
* if (cached !== undefined) {
146-
* console.log("using cached", cached.port);
147-
* }
196+
* const result = config.peek();
197+
* assertEquals(result, { ok: true, value: { port: 8080 } });
148198
* ```
149199
*
150-
* @returns The resolved value, or `undefined` if not yet initialized or still in-flight.
200+
* @example Not yet initialized
201+
* ```ts
202+
* import { Lazy } from "@std/async/unstable-lazy";
203+
* import { assertEquals } from "@std/assert";
204+
*
205+
* const lazy = new Lazy(() => 42);
206+
* assertEquals(lazy.peek(), { ok: false });
207+
* ```
208+
*
209+
* @experimental **UNSTABLE**: New API, yet to be vetted.
210+
*
211+
* @returns `{ ok: true, value }` if the value has been initialized, or
212+
* `{ ok: false }` if not yet initialized or still in-flight.
151213
*/
152-
peek(): T | undefined {
153-
return this.#settled ? this.#value : undefined;
214+
peek(): { ok: true; value: T } | { ok: false } {
215+
return this.#settled
216+
? { ok: true, value: this.#value as T }
217+
: { ok: false };
154218
}
155219

156220
/**
157221
* Resets the lazy so the next {@linkcode get} re-runs the initializer. Does
158222
* not cancel an in-flight initialization; callers that already have the
159223
* promise will still receive its result.
160224
*
161-
* @experimental **UNSTABLE**: New API, yet to be vetted.
162-
*
163225
* @example Force reload
164226
* ```ts ignore
165227
* import { Lazy } from "@std/async/unstable-lazy";
@@ -169,6 +231,8 @@ export class Lazy<T> {
169231
* config.reset();
170232
* const fresh = await config.get();
171233
* ```
234+
*
235+
* @experimental **UNSTABLE**: New API, yet to be vetted.
172236
*/
173237
reset(): void {
174238
this.#promise = undefined;

0 commit comments

Comments
 (0)