Skip to content
Merged
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
77 changes: 66 additions & 11 deletions src/maybe/maybe.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -534,25 +534,80 @@ export interface IMaybe<T> extends IMonad<T> {
flatMapMany<R>(fn: (val: NonNullable<T>) => Promise<R>[]): Promise<IMaybe<NonNullable<R>[]>>

/**
* Combines this Maybe with another Maybe using a combiner function.
*
* If both Maybes are Some, applies the function to their values and returns
* a new Some containing the result. If either is None, returns None.
*
* Combines this Maybe with one or more other Maybes using a combiner function.
*
* If all Maybes are Some, applies the function to their values and returns
* a new Some containing the result. If any is None, returns None.
*
* @typeParam U - The type of the value in the other Maybe
* @typeParam R - The type of the combined result
* @param other - Another Maybe to combine with this one
* @param fn - A function that combines the values from both Maybes
* @returns A new Maybe containing the combined result if both inputs are Some, otherwise None
*
* @returns A new Maybe containing the combined result if all inputs are Some, otherwise None
*
* @example
* // Combine user name and email into a display string
* // Combine two values
* const name = maybe(user.name);
* const email = maybe(user.email);
*
*
* const display = name.zipWith(email, (name, email) => `${name} <${email}>`);
* // Some("John Doe <john@example.com>") if both name and email exist
* // Some("John Doe <john@example.com>") if both exist
* // None if either is missing
*
* @example
* // Combine three values
* const firstName = maybe(user.firstName);
* const lastName = maybe(user.lastName);
* const email = maybe(user.email);
*
* const contact = firstName.zipWith(lastName, email, (first, last, email) => ({
* fullName: `${first} ${last}`,
* email
* }));
* // Some({ fullName: "John Doe", email: "john@example.com" }) if all exist
* // None if any is missing
*
* @example
* // Combine many values
* const result = a.zipWith(b, c, d, e, (a, b, c, d, e) => a + b + c + d + e);
*/
zipWith<U extends NonNullable<unknown>, R>(other: IMaybe<U>, fn: (a: NonNullable<T>, b: U) => NonNullable<R>): IMaybe<R>
zipWith<U extends NonNullable<unknown>, R>(
other: IMaybe<U>,
fn: (a: NonNullable<T>, b: U) => NonNullable<R>
): IMaybe<R>

zipWith<U extends NonNullable<unknown>, V extends NonNullable<unknown>, R>(
m1: IMaybe<U>,
m2: IMaybe<V>,
fn: (a: NonNullable<T>, b: U, c: V) => NonNullable<R>
): IMaybe<R>

zipWith<U extends NonNullable<unknown>, V extends NonNullable<unknown>, W extends NonNullable<unknown>, R>(
m1: IMaybe<U>,
m2: IMaybe<V>,
m3: IMaybe<W>,
fn: (a: NonNullable<T>, b: U, c: V, d: W) => NonNullable<R>
): IMaybe<R>

zipWith<U extends NonNullable<unknown>, V extends NonNullable<unknown>, W extends NonNullable<unknown>, X extends NonNullable<unknown>, R>(
m1: IMaybe<U>,
m2: IMaybe<V>,
m3: IMaybe<W>,
m4: IMaybe<X>,
fn: (a: NonNullable<T>, b: U, c: V, d: W, e: X) => NonNullable<R>
): IMaybe<R>

zipWith<U extends NonNullable<unknown>, V extends NonNullable<unknown>, W extends NonNullable<unknown>, X extends NonNullable<unknown>, Z extends NonNullable<unknown>, R>(
m1: IMaybe<U>,
m2: IMaybe<V>,
m3: IMaybe<W>,
m4: IMaybe<X>,
m5: IMaybe<Z>,
fn: (a: NonNullable<T>, b: U, c: V, d: W, e: X, f: Z) => NonNullable<R>
): IMaybe<R>

// Variadic overload for 5+ Maybes
zipWith<R>(
...args: [...IMaybe<NonNullable<unknown>>[], (...values: NonNullable<unknown>[]) => NonNullable<R>]
): IMaybe<R>
Comment on lines +609 to +612
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current variadic overload type:

zipWith<R>(
  ...args: [...IMaybe<NonNullable<unknown>>[], (...values: NonNullable<unknown>[]) => NonNullable<R>]
): IMaybe<R>

implicitly allows calling zipWith without any other Maybes, e.g. maybe(1).zipWith(x => x + 1) at the type level (the tuple [] is a valid IMaybe<NonNullable<unknown>>[]). This contradicts the JSDoc examples and the intended semantics of “Combines this Maybe with one or more other Maybes”.

This mismatch between the documented contract and the type-level contract can lead to confusing or unsafe usage, especially given the generic unknown-based typing here. It would be better to encode “at least one other Maybe” in the signature itself.

Suggestion

You can enforce the presence of at least one additional Maybe in the variadic overload by changing the tuple rest pattern, for example:

zipWith<R>(
  ...args: [IMaybe<NonNullable<unknown>>, ...IMaybe<NonNullable<unknown>>[], (...values: NonNullable<unknown>[]) => NonNullable<R>]
): IMaybe<R>

This preserves the existing overloads for up to five extra Maybes while making the fallback overload consistent with the documented requirement of “one or more other Maybes.” Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion.

Comment on lines +609 to +612
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new overloads cover arities 2–6 plus a variadic signature, which is powerful but also introduces a somewhat tricky call contract: callers must always pass n Maybes plus one final combiner function. The implementation in Maybe#zipWith assumes the last argument is the function and does no runtime validation, so a mis-ordered call will silently manifest as a logic bug rather than a clear error.

Given how overloaded this API has become, it might be worth tightening the public surface a bit:

  • Either clearly document in the interface comment that the last argument must be the combiner function and that all preceding arguments must be IMaybes, or
  • Consider removing the variadic overload and capping at the explicit 2–6 overloads for stricter type-checked usage.

Right now consumers could still do something like a.zipWith(b as any, c, d) and hit surprising runtime behavior without any guardrails.

Suggestion

You could tighten the contract and improve debuggability either by documenting the calling convention explicitly or by validating the last argument at runtime. For example, a lightweight runtime guard:

zipWith<R>(
  ...args: [...IMaybe<NonNullable<unknown>>[], (...values: NonNullable<unknown>[]) => NonNullable<R>]
): IMaybe<R>;

and in the implementation:

public zipWith<R>(...args: unknown[]): IMaybe<R> {
  if (this.isNone()) {
    return new Maybe<R>();
  }

  const fn = args[args.length - 1];
  if (typeof fn !== 'function') {
    throw new TypeError('zipWith: last argument must be a function');
  }

  const maybes = args.slice(0, -1) as IMaybe<unknown>[];
  // ...rest stays the same
}

This keeps the strong typing while turning obvious misuses into clear runtime errors instead of silent mis-computation. Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion.

}
103 changes: 100 additions & 3 deletions src/maybe/maybe.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -858,13 +858,110 @@ describe('Maybe', () => {
it('should return None if both values are None', () => {
const first = maybe<string>()
const second = maybe<string>()

const result = first.zipWith(second, (a, b) => `${a}, ${b}!`)


expect(result.isNone()).toBe(true)
})

it('should combine three Some values', () => {
const a = maybe(1)
const b = maybe(2)
const c = maybe(3)

const result = a.zipWith(b, c, (x, y, z) => x + y + z)

expect(result.isSome()).toBe(true)
expect(result.valueOr(0)).toBe(6)
})

it('should return None if any of three values is None', () => {
const a = maybe(1)
const b = maybe<number>()
const c = maybe(3)

const result = a.zipWith(b, c, (x, y, z) => x + y + z)

expect(result.isNone()).toBe(true)
})

it('should combine four Some values', () => {
const a = maybe('a')
const b = maybe('b')
const c = maybe('c')
const d = maybe('d')

const result = a.zipWith(b, c, d, (w, x, y, z) => w + x + y + z)

expect(result.isSome()).toBe(true)
expect(result.valueOr('')).toBe('abcd')
})

it('should return None if any of four values is None', () => {
const a = maybe('a')
const b = maybe('b')
const c = maybe<string>()
const d = maybe('d')

const result = a.zipWith(b, c, d, (w, x, y, z) => w + x + y + z)

expect(result.isNone()).toBe(true)
})

it('should combine five Some values', () => {
const a = maybe(1)
const b = maybe(2)
const c = maybe(3)
const d = maybe(4)
const e = maybe(5)

const result = a.zipWith(b, c, d, e, (v1, v2, v3, v4, v5) => v1 + v2 + v3 + v4 + v5)

expect(result.isSome()).toBe(true)
expect(result.valueOr(0)).toBe(15)
})

it('should return None if any of five values is None', () => {
const a = maybe(1)
const b = maybe(2)
const c = maybe(3)
const d = maybe(4)
const e = maybe<number>()

const result = a.zipWith(b, c, d, e, (v1, v2, v3, v4, v5) => v1 + v2 + v3 + v4 + v5)

expect(result.isNone()).toBe(true)
})

it('should return None if any of six values is None', () => {
const a = maybe(1)
const b = maybe(2)
const c = maybe(3)
const d = maybe(4)
const e = maybe(5)
const f = maybe<number>()

const result = a.zipWith(b, c, d, e, f, (v1, v2, v3, v4, v5, v6) => v1 + v2 + v3 + v4 + v5 + v6)

expect(result.isNone()).toBe(true)
})

it('should work with mixed types', () => {
const name = maybe('John')
const age = maybe(30)
const active = maybe(true)

const result = name.zipWith(age, active, (n, a, act) => ({ name: n, age: a, active: act }))

expect(result.isSome()).toBe(true)
expect(result.valueOr({ name: '', age: 0, active: false })).toEqual({
name: 'John',
age: 30,
active: true
})
})
})

describe('flatMapMany', () => {
it('should execute multiple promises in parallel when Some', async () => {
const source = maybe(42)
Expand Down
20 changes: 18 additions & 2 deletions src/maybe/maybe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,23 @@ export class Maybe<T> implements IMaybe<T> {
.catch(() => new Maybe<NonNullable<R>[]>())
}

public zipWith<U extends NonNullable<unknown>, R>(other: IMaybe<U>, fn: (a: NonNullable<T>, b: U) => NonNullable<R>): IMaybe<R> {
return this.flatMap(a => other.map(b => fn(a, b)))
public zipWith<R>(...args: unknown[]): IMaybe<R> {
if (this.isNone()) {
return new Maybe<R>()
}

const fn = args[args.length - 1] as (...values: unknown[]) => NonNullable<R>
const maybes = args.slice(0, -1) as IMaybe<unknown>[]

const values: unknown[] = [this.value]

for (const m of maybes) {
if (m.isNone()) {
return new Maybe<R>()
}
values.push(m.valueOrThrow())
}

return new Maybe<R>(fn(...values))
Comment on lines +304 to +321
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation assumes that the last argument is always a function and that there is at least one other IMaybe provided, but these invariants are only enforced by the TypeScript overloads.

At runtime, if zipWith is ever called with:

  • Only a function (a.zipWith(fn)), or
  • No trailing function (e.g. mis-usage via any or loose typing),

fn here will either be undefined or a non-function and fn(...values) below will throw. While the types should prevent this in well-typed code, this is a low-level core utility where defensive checks are relatively cheap and can fail fast with a clearer error.

Given the library appears to aim for robustness in other places (e.g. flatMapMany catch returning None), a minimal runtime validation would improve resilience and debuggability, especially for consumers using any or JS interop.

Suggestion

Consider adding minimal runtime validation for the function and argument count, for example:

public zipWith<R>(...args: unknown[]): IMaybe<R> {
  if (this.isNone()) {
    return new Maybe<R>()
  }

  if (args.length === 0) {
    throw new Error('zipWith requires at least one other Maybe and a combiner function')
  }

  const fn = args[args.length - 1]
  if (typeof fn !== 'function') {
    throw new Error('zipWith last argument must be a function')
  }

  const maybes = args.slice(0, -1) as IMaybe<unknown>[]
  const values: unknown[] = [this.value]

  for (const m of maybes) {
    if (m.isNone()) {
      return new Maybe<R>()
    }
    values.push(m.valueOrThrow())
  }

  return new Maybe<R>((fn as (...values: unknown[]) => Nonnullable<R>)(...values))
}

This keeps the overload behavior intact while offering a clearer failure mode when misused from less strict code. Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion.

}
}