Skip to content

Commit e0b8a12

Browse files
feat: add async maybe transforms (#205)
1 parent affe4be commit e0b8a12

17 files changed

+1131
-56
lines changed

.eslintrc.json

+1-2
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,7 @@
2222
],
2323
"extends": [
2424
"plugin:@typescript-eslint/eslint-recommended",
25-
"plugin:@typescript-eslint/recommended",
26-
"plugin:promise/recommended"
25+
"plugin:@typescript-eslint/recommended"
2726
],
2827
"env": {
2928
"node": true,

src/maybe/maybe.factory.spec.ts

+85-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { maybe, none, some } from './maybe.factory'
1+
import { maybe, none, some, maybeProps } from './maybe.factory'
22

33
describe('should construct maybes', () => {
44
it('should handle "maybe" case', () => {
@@ -14,3 +14,87 @@ describe('should construct maybes', () => {
1414
expect(some('test').isSome()).toEqual(true)
1515
})
1616
})
17+
18+
describe('maybeProps', () => {
19+
it('should return Some for valid property paths', () => {
20+
const user = {
21+
profile: {
22+
contact: {
23+
24+
}
25+
}
26+
}
27+
28+
const getEmail = maybeProps<typeof user>('profile.contact.email')
29+
const result = getEmail(user)
30+
31+
expect(result.isSome()).toEqual(true)
32+
expect(result.valueOr('default')).toEqual('[email protected]')
33+
})
34+
35+
it('should return None for invalid property paths', () => {
36+
const user = {
37+
profile: {
38+
contact: {
39+
// No email property
40+
}
41+
}
42+
}
43+
44+
const getEmail = maybeProps<typeof user>('profile.contact.email')
45+
const result = getEmail(user)
46+
47+
expect(result.isNone()).toEqual(true)
48+
})
49+
50+
it('should return None for null intermediate values', () => {
51+
const user = {
52+
profile: null
53+
}
54+
55+
const getEmail = maybeProps<typeof user>('profile.contact.email')
56+
const result = getEmail(user)
57+
58+
expect(result.isNone()).toEqual(true)
59+
})
60+
61+
it('should handle arrays in paths', () => {
62+
const data = {
63+
users: [
64+
{ name: 'User 1' },
65+
{ name: 'User 2' }
66+
]
67+
}
68+
69+
const getFirstUserName = maybeProps<typeof data>('users.0.name')
70+
const result = getFirstUserName(data)
71+
72+
expect(result.isSome()).toEqual(true)
73+
expect(result.valueOr('default')).toEqual('User 1')
74+
})
75+
76+
it('should return None for out-of-bounds array indices', () => {
77+
const data = {
78+
users: [
79+
{ name: 'User 1' }
80+
]
81+
}
82+
83+
const getNonExistentUser = maybeProps<typeof data>('users.5.name')
84+
const result = getNonExistentUser(data)
85+
86+
expect(result.isNone()).toEqual(true)
87+
})
88+
89+
it('should work with single-segment paths', () => {
90+
const data = {
91+
name: 'Test'
92+
}
93+
94+
const getName = maybeProps<typeof data>('name')
95+
const result = getName(data)
96+
97+
expect(result.isSome()).toEqual(true)
98+
expect(result.valueOr('default')).toEqual('Test')
99+
})
100+
})

src/maybe/maybe.factory.ts

+47-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1+
import type { IMaybe } from './maybe.interface'
12
import { Maybe } from './maybe'
2-
import { IMaybe } from './maybe.interface'
33

4-
export function maybe<T>(value?: T | null): Maybe<T> {
4+
export function maybe<T>(value?: T | null): IMaybe<T> {
55
return new Maybe<T>(value)
66
}
77

@@ -12,3 +12,48 @@ export function none<T>(): IMaybe<T> {
1212
export function some<T>(value: T): IMaybe<T> {
1313
return maybe(value)
1414
}
15+
16+
/**
17+
* Creates a function that returns a Maybe for the given property path.
18+
*
19+
* This is a powerful utility for safely navigating nested object structures.
20+
* It creates a type-safe property accessor function that returns a Maybe
21+
* containing the value at the specified path if it exists, or None if any
22+
* part of the path is missing.
23+
*
24+
* @param path A dot-separated string path to the desired property
25+
* @returns A function that takes an object and returns a Maybe of the property value
26+
*
27+
* @example
28+
* const getEmail = maybeProps<User>('profile.contact.email');
29+
*
30+
* // Later in code
31+
* const emailMaybe = getEmail(user);
32+
* // Returns Some(email) if user.profile.contact.email exists
33+
* // Returns None if any part of the path is undefined/null
34+
*
35+
* // Use with filter
36+
* const validEmail = getEmail(user).filter(email => email.includes('@'));
37+
*
38+
* // Use with match
39+
* getEmail(user).match({
40+
* some: email => sendVerification(email),
41+
* none: () => showEmailPrompt()
42+
* });
43+
*/
44+
export function maybeProps<T, R = unknown>(path: string): (obj: T) => IMaybe<R> {
45+
const segments = path.split('.')
46+
47+
return (obj: T): IMaybe<R> => {
48+
let current = obj as unknown
49+
50+
for (const segment of segments) {
51+
if (current === null || current === undefined || !(segment in (current as Record<string, unknown>))) {
52+
return none<R>()
53+
}
54+
current = (current as Record<string, unknown>)[segment]
55+
}
56+
57+
return maybe<R>(current as R)
58+
}
59+
}

src/maybe/maybe.interface.ts

+120
Original file line numberDiff line numberDiff line change
@@ -435,4 +435,124 @@ export interface IMaybe<T> extends IMonad<T> {
435435
* // Converts a Maybe<User> to a Result<User, Error>
436436
*/
437437
toResult<E>(error: E): IResult<T, E>
438+
439+
/**
440+
* Chains Maybe operations with a function that returns a Promise.
441+
*
442+
* This allows for seamless integration with asynchronous operations.
443+
* The Promise result is automatically wrapped in a Maybe, with null/undefined
444+
* or rejected promises resulting in None.
445+
*
446+
* Note on resolution preservation: This method preserves both the Maybe context and
447+
* the asynchronous nature of Promises:
448+
* - None values short-circuit (the Promise-returning function is never called)
449+
* - Some values are passed to the function, and its Promise result is processed
450+
* - Promise rejections become None values in the resulting Maybe
451+
* - Promise resolutions become Some values if non-nullish, None otherwise
452+
*
453+
* This approach preserves the monadic semantics while adding asynchronicity.
454+
*
455+
* @typeParam R - The type of the value in the resulting Promise
456+
* @param fn - A function that takes the value from this Maybe and returns a Promise
457+
* @returns A Promise that resolves to a Maybe containing the resolved value
458+
*
459+
* @example
460+
* maybe(userId)
461+
* .flatMapPromise(id => api.fetchUserProfile(id))
462+
* .then(profileMaybe => profileMaybe.match({
463+
* some: profile => displayProfile(profile),
464+
* none: () => showProfileNotFound()
465+
* }));
466+
*
467+
* // Chain multiple promises
468+
* maybe(user)
469+
* .flatMapPromise(user => fetchPermissions(user.id))
470+
* .then(permissionsMaybe => permissionsMaybe.flatMap(permissions =>
471+
* maybe(user).map(user => ({ ...user, permissions }))
472+
* ))
473+
* .then(userWithPermissions => renderUserDashboard(userWithPermissions));
474+
*/
475+
flatMapPromise<R>(fn: (val: NonNullable<T>) => Promise<R>): Promise<IMaybe<NonNullable<R>>>
476+
477+
/**
478+
* Chains Maybe operations with a function that returns an Observable.
479+
*
480+
* This allows for seamless integration with reactive streams.
481+
* The Observable result is automatically wrapped in a Maybe, with null/undefined
482+
* or empty/error emissions resulting in None.
483+
*
484+
* Note on resolution transformation: This method transforms between context types while
485+
* preserving semantic meaning:
486+
* - None values short-circuit (the Observable-returning function is never called)
487+
* - Some values are passed to the function to generate an Observable
488+
* - Only the first emission from the Observable is captured (timing loss)
489+
* - Observable emissions become Some values in the resulting Maybe
490+
* - Observable completion without emissions or errors becomes None
491+
* - Observable errors become None values
492+
*
493+
* There is timing model transformation: from continuous reactive to one-time asynchronous.
494+
*
495+
* @typeParam R - The type of the value emitted by the resulting Observable
496+
* @param fn - A function that takes the value from this Maybe and returns an Observable
497+
* @returns A Promise that resolves to a Maybe containing the first emitted value
498+
*
499+
* @requires rxjs@^7.0
500+
* @example
501+
* maybe(userId)
502+
* .flatMapObservable(id => userService.getUserSettings(id))
503+
* .then(settingsMaybe => settingsMaybe.match({
504+
* some: settings => applyUserSettings(settings),
505+
* none: () => applyDefaultSettings()
506+
* }));
507+
*/
508+
flatMapObservable<R>(fn: (val: NonNullable<T>) => import('rxjs').Observable<R>): Promise<IMaybe<NonNullable<R>>>
509+
510+
/**
511+
* Maps and flattens multiple Promises in parallel, preserving the Maybe context.
512+
*
513+
* This operation allows processing an array of async operations concurrently
514+
* while maintaining the Maybe context. If the original Maybe is None, the
515+
* function is never called. Otherwise, all Promises are executed in parallel.
516+
*
517+
* @typeParam R - The type returned by each Promise in the results array
518+
* @param fn - A function that takes the value from this Maybe and returns an array of Promises
519+
* @returns A Promise that resolves to a Maybe containing an array of results
520+
*
521+
* @example
522+
* // Load multiple resources concurrently from a user ID
523+
* maybe(userId)
524+
* .flatMapMany(id => [
525+
* api.fetchProfile(id),
526+
* api.fetchPermissions(id),
527+
* api.fetchSettings(id)
528+
* ])
529+
* .then(resultsMaybe => resultsMaybe.match({
530+
* some: ([profile, permissions, settings]) => displayDashboard(profile, permissions, settings),
531+
* none: () => showError('Failed to load user data')
532+
* }));
533+
*/
534+
flatMapMany<R>(fn: (val: NonNullable<T>) => Promise<R>[]): Promise<IMaybe<NonNullable<R>[]>>
535+
536+
/**
537+
* Combines this Maybe with another Maybe using a combiner function.
538+
*
539+
* If both Maybes are Some, applies the function to their values and returns
540+
* a new Some containing the result. If either is None, returns None.
541+
*
542+
* @typeParam U - The type of the value in the other Maybe
543+
* @typeParam R - The type of the combined result
544+
* @param other - Another Maybe to combine with this one
545+
* @param fn - A function that combines the values from both Maybes
546+
* @returns A new Maybe containing the combined result if both inputs are Some, otherwise None
547+
*
548+
* @example
549+
* // Combine user name and email into a display string
550+
* const name = maybe(user.name);
551+
* const email = maybe(user.email);
552+
*
553+
* const display = name.zipWith(email, (name, email) => `${name} <${email}>`);
554+
* // Some("John Doe <[email protected]>") if both name and email exist
555+
* // None if either is missing
556+
*/
557+
zipWith<U extends NonNullable<unknown>, R>(other: IMaybe<U>, fn: (a: NonNullable<T>, b: U) => NonNullable<R>): IMaybe<R>
438558
}

0 commit comments

Comments
 (0)