Skip to content
This repository was archived by the owner on Aug 4, 2026. It is now read-only.

Commit 6a4c9de

Browse files
committed
feat: add first op to Maybe and MaybeAsync
1 parent c26c6e1 commit 6a4c9de

5 files changed

Lines changed: 128 additions & 10 deletions

File tree

src/AsyncMaybe.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Maybe } from "./Maybe";
2-
import type { ReturnMaybeType } from "./types";
2+
import type { ExtractMaybeArrayValue, ReturnMaybeType } from "./types";
33
import { isJust, isNothing } from "./utils";
44

55
/**
@@ -246,6 +246,28 @@ export class AsyncMaybe<T> {
246246
return new AsyncMaybe(next);
247247
}
248248

249+
first<Us extends ReadonlyArray<AsyncMaybe<unknown> | Maybe<unknown>>>(
250+
fn: (value: T) => Us,
251+
): AsyncMaybe<ExtractMaybeArrayValue<Us>> {
252+
const next = (async () => {
253+
const value = await this._value;
254+
255+
if (isNothing(value)) return value as unknown as ExtractMaybeArrayValue<Us>;
256+
257+
const maybes = fn(value);
258+
259+
for (const m of maybes) {
260+
const maybeValue = await Promise.resolve(m.value());
261+
262+
if (isJust(maybeValue)) return maybeValue as ExtractMaybeArrayValue<Us>;
263+
}
264+
265+
return null as unknown as ExtractMaybeArrayValue<Us>;
266+
})();
267+
268+
return new AsyncMaybe(next);
269+
}
270+
249271
/**
250272
* withDefault - Provides a default value if Nothing.
251273
*

src/Maybe.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
2-
import type { ReturnMaybeType } from "./types";
2+
import type { ExtractMaybeArrayValue, ReturnMaybeType } from "./types";
33
import { isJust, isNothing } from "./utils";
44

55
/**
@@ -179,6 +179,28 @@ export class Maybe<T> {
179179
return new Maybe(result);
180180
}
181181

182+
/**
183+
* Returns the first Maybe to produce a non-Nothing value
184+
*
185+
* @typeParam U - The element type of the resulting Maybe.
186+
* @param fn - A function mapping the contained value to an array of Maybes.
187+
* @returns The first Maybe<U> from the array, or Nothing if none found or if this is Nothing.
188+
*/
189+
first<Us extends ReadonlyArray<Maybe<any>>>(
190+
fn: (value: T) => Us,
191+
): Maybe<ExtractMaybeArrayValue<Us>> {
192+
if (isNothing(this._value)) return new Maybe<ExtractMaybeArrayValue<Us>>(null);
193+
194+
const maybes = fn(this._value as T);
195+
196+
for (const m of maybes) {
197+
if (!(m instanceof Maybe)) return new Maybe<ExtractMaybeArrayValue<Us>>(null);
198+
if (isJust(m.value())) return m;
199+
}
200+
201+
return new Maybe<ExtractMaybeArrayValue<Us>>(null);
202+
}
203+
182204
/**
183205
* Access the raw inner value.
184206
*

src/__tests__/AsyncMaybe.test.ts

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -100,21 +100,21 @@ describe("AsyncMaybe", () => {
100100
});
101101

102102
it("short circuits if Nothing", async () => {
103-
// @ts-expect-error
103+
// @ts-expect-error: testing null case
104104
const m = AsyncMaybe.fromPromise<number[] | null>(Promise.resolve(null)).filterMap((x) =>
105105
Maybe.fromNullable(x * 10),
106106
);
107107
expect(await m.value()).toBeNull();
108108

109-
// @ts-expect-error
109+
// @ts-expect-error: testing undefined case
110110
const m2 = AsyncMaybe.fromPromise<number[] | undefined>(Promise.resolve(undefined)).filterMap(
111111
(x) => Maybe.fromNullable(x * 10),
112112
);
113113
expect(await m2.value()).toBeUndefined();
114114
});
115115

116116
it("returns null if the value is not an array", async () => {
117-
// @ts-expect-error
117+
// @ts-expect-error: testing non-array case
118118
const m = AsyncMaybe.fromPromise(Promise.resolve(123)).filterMap((x) =>
119119
Maybe.fromNullable(x),
120120
);
@@ -132,15 +132,15 @@ describe("AsyncMaybe", () => {
132132

133133
it("short circuits if the value is not an object", async () => {
134134
const m = AsyncMaybe.fromPromise(Promise.resolve(5)).extend("greet", (o) =>
135-
// @ts-expect-error
135+
// @ts-expect-error: testing non-object case
136136
AsyncMaybe.fromNullable(`Hello ${o.num}`),
137137
);
138138
expect(await m.value()).toBeNull();
139139
});
140140

141141
it("does not confuse null for an object", async () => {
142142
const m = AsyncMaybe.fromPromise(Promise.resolve(null)).extend("greet", (o) =>
143-
// @ts-expect-error
143+
// @ts-expect-error: testing null case
144144
AsyncMaybe.fromNullable(`Hello ${o.num}`),
145145
);
146146
expect(await m.value()).toBeNull();
@@ -155,7 +155,7 @@ describe("AsyncMaybe", () => {
155155

156156
it("short circuits if the function returns Nothing", async () => {
157157
const m = AsyncMaybe.fromPromise(Promise.resolve({ num: 2 }))
158-
.extend("greet", (o) => AsyncMaybe.fromNullable<string>(null))
158+
.extend("greet", () => AsyncMaybe.fromNullable<string>(null))
159159
.map((obj) => `${obj.greet} stranger!`);
160160
expect(await m.value()).toBeNull();
161161
});
@@ -260,6 +260,43 @@ describe("AsyncMaybe", () => {
260260
});
261261
});
262262

263+
describe("first", () => {
264+
it("returns the first Just value from an array", async () => {
265+
const m = AsyncMaybe.fromNullable(1);
266+
const first = m.first((value) => [
267+
AsyncMaybe.fromNullable(null),
268+
AsyncMaybe.fromNullable(2),
269+
AsyncMaybe.fromNullable(value + 10),
270+
]);
271+
expect(await first.value()).toBe(2);
272+
});
273+
274+
it("returns Nothing if all are Nothing", async () => {
275+
const m = AsyncMaybe.fromNullable(1);
276+
const first = m.first(() => [
277+
AsyncMaybe.fromNullable<number | null>(null),
278+
AsyncMaybe.fromNullable<number | undefined>(undefined),
279+
]);
280+
expect(await first.value()).toBeNull();
281+
});
282+
283+
it("short circuits if the original is Nothing", async () => {
284+
const m = AsyncMaybe.fromNullable<number>(null);
285+
const first = m.first(() => [AsyncMaybe.fromNullable(2), AsyncMaybe.fromNullable(3)]);
286+
expect(await first.value()).toBeNull();
287+
});
288+
289+
it("handles both AsyncMaybe and Maybe values", async () => {
290+
const m = AsyncMaybe.fromNullable(5);
291+
const first = m.first((value) => [
292+
Maybe.fromNullable<number>(null),
293+
AsyncMaybe.fromPromise(Promise.resolve(value * 2)),
294+
Maybe.fromNullable(value + 3),
295+
]);
296+
expect(await first.value()).toBe(10);
297+
});
298+
});
299+
263300
describe("withDefault / getOrElse", () => {
264301
it("withDefault supplies a value if Nothing", async () => {
265302
const absent = AsyncMaybe.fromPromise<number | null>(Promise.resolve(null)).withDefault(42);

src/__tests__/Maybe.test.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ describe("Maybe", () => {
119119

120120
it("short circuits if the `Just` value is not an object", () => {
121121
const result = Maybe.fromNullable(5)
122-
// @ts-expect-error
122+
// @ts-expect-error: should error because 5 is not an object
123123
.extend("greet", (o) => Maybe.fromNullable(`Hello ${o.num}`))
124124
.value();
125125

@@ -224,7 +224,7 @@ describe("Maybe", () => {
224224
it("fails if the result of the filterMap callback does not return a Maybe instance", () => {
225225
const arr = [{ greet: "a" }, { greet: "b" }];
226226
const maybeArr = Maybe.fromNullable(arr)
227-
// @ts-expect-error
227+
// @ts-expect-error: should error because the callback does not return a Maybe
228228
.filterMap((x) => x.greet) // Incorrect: should return Maybe
229229
.value();
230230

@@ -234,6 +234,7 @@ describe("Maybe", () => {
234234

235235
describe("effect", () => {
236236
it("calls a function with the value and ignores its result", () => {
237+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
237238
const effectFn = mock((x: number) => {});
238239
const result = Maybe.fromNullable(10)
239240
.effect(effectFn)
@@ -246,6 +247,7 @@ describe("Maybe", () => {
246247
});
247248

248249
it("does not call a function when the value is Nothing", () => {
250+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
249251
const effectFn = mock((x: number) => {});
250252
const result = Maybe.fromNullable<number>(null).effect(effectFn).value();
251253

@@ -254,6 +256,37 @@ describe("Maybe", () => {
254256
});
255257
});
256258

259+
describe("first", () => {
260+
it("returns the first Just value from an array of Maybes", () => {
261+
const maybe = Maybe.fromNullable(1);
262+
const first = maybe
263+
.first((value) => [
264+
Maybe.fromNullable(null as null | boolean),
265+
Maybe.fromNullable(value * 3),
266+
Maybe.fromNullable("test"),
267+
])
268+
.value();
269+
expect(first).toBe(3);
270+
});
271+
272+
it("returns Nothing if all Maybes are Nothing", () => {
273+
const maybe = Maybe.fromNullable(1);
274+
const first = maybe
275+
.first(() => [
276+
Maybe.fromNullable(null as null | boolean),
277+
Maybe.fromNullable(undefined as undefined | number),
278+
])
279+
.value();
280+
expect(first).toBeNull();
281+
});
282+
283+
it("short circuits if the original Maybe is Nothing", () => {
284+
const maybe = Maybe.fromNullable<number>(null);
285+
const first = maybe.first(() => [Maybe.fromNullable(1), Maybe.fromNullable(2)]).value();
286+
expect(first).toBeNull();
287+
});
288+
});
289+
257290
it("complex chaining example behaves as expected", () => {
258291
const x: number | null = 10;
259292
const result = Maybe.fromNullable(x)

src/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,7 @@ export type ExtractValue<M> =
1212
: never;
1313

1414
export type ReturnMaybeType<Fn extends (...args: any[]) => any> = ExtractValue<ReturnType<Fn>>;
15+
16+
export type AnyMaybe<T> = Maybe<T> | AsyncMaybe<T>;
17+
export type ExtractMaybeArrayValue<M extends ReadonlyArray<Maybe<any> | AsyncMaybe<any>>> =
18+
M[number] extends AnyMaybe<infer T> ? T : never;

0 commit comments

Comments
 (0)