Skip to content

shuding/better-all

Repository files navigation

better-all

Promise.all with automatic dependency optimization and full type inference.

Why?

When you have tasks with dependencies, the common Promise.all pattern is sometimes inefficient:

// Common pattern: Sequential execution wastes time
const [a, b] = await Promise.all([getA(), getB()])  // a: 1s, b: 10s → takes 10s
const c = await getC(a)                             // c: 10s → takes 10s
// Total: 20 seconds

You could optimize this manually by parallelizing b and c:

const a = await getA()               // a: 1s -> takes 1s
const [b, c] = await Promise.all([   // b: 10s, c: 10s -> takes 10s
  getB(),
  getC(a)
])
// Total: 11 seconds

But what if the durations of these methods change (i.e. unstable network latency)? Say getA() now takes 10 seconds and getC() takes 1 second. The previous manual optimization becomes suboptimal again, compared to the naive approach:

const a = await getA()              // a: 10s -> takes 10s
const [b, c] = await Promise.all([  // b: 10s, c: 1s -> takes 10s
  getB(),
  getC(a)
])
// Total: 20 seconds

// Naive approach:
const [a, b] = await Promise.all([getA(), getB()])  // a: 10s, b: 10s → takes 10s
const c = await getC(a)                             // c: 1s → takes 1s
// Total: 11 seconds

To correctly optimize such cases using Promise.all, you'd have to manually analyze and declare the dependency graph:

const [[a, c], b] = await Promise.all([
  getA().then(a => getC(a).then(c => [a, c])),
  getB()
])

This quickly becomes unmanageable in real-world scenarios with many tasks and complex dependencies, not to mention the loss of readability.

In real-world application code, there are more downsides of the naive approach and ad-hoc promise adjustments. Give this a read if you are still not convinced.

Better Promise.all

This library solves it automatically:

import { all } from 'better-all'

const { a, b, c } = await all({
  async a() { return getA() },               // 1s
  async b() { return getB() },               // 10s
  async c() { return getC(await this.$.a) }  // 10s (waits for a)
})
// Total: 11 seconds - optimal parallelization!

all automatically kicks off all tasks immediately, and when hitting an await this.$.dependency, it waits for that specific task to complete.

The magical this.$ object gives you access to all other task results as promises, allowing you to express dependencies naturally.

The library ensures maximal parallelization automatically.

Installation

npm install better-all
# or
pnpm add better-all
# or
bun add better-all
# or
yarn add better-all

Features

  • Full type inference: Both results and dependencies are fully typed
  • Automatic maximal parallelization: Independent tasks run in parallel
  • Object-based API: Minimal cognitive load, easy to read
  • No hanging promises: Avoids the uncaught dangling promises problem often seen in manual optimization
  • Auto-abort on failure: Cancel remaining tasks when one fails via this.$signal
  • Debug mode with waterfall visualization: See exactly how tasks execute with ASCII waterfall charts
  • Early exit support: Exit flows early when a result is determined
  • Lightweight: Minimal dependencies and small bundle size

API

all(tasks, options?)

Execute tasks with automatic dependency resolution.

  • tasks: Object of async task functions
  • options: Optional configuration object
    • debug: Set to true to output a waterfall chart showing task execution timeline
    • signal: An AbortSignal to abort all tasks externally
  • Each task function receives:
    • this.$ - an object with promises for all task results
    • this.$signal - an AbortSignal that aborts when any sibling task fails
  • Returns a promise that resolves to an object with all task results
  • Rejects if any task fails (like Promise.all)

allSettled(tasks, options?)

Execute tasks with automatic dependency resolution, returning settled results for all tasks.

  • tasks: Object of async task functions
  • options: Optional configuration object
    • debug: Set to true to output a waterfall chart showing task execution timeline
    • signal: An AbortSignal to abort all tasks externally
  • Each task function receives:
    • this.$ - an object with promises for all task results
    • this.$signal - an AbortSignal (only aborts on external signal, not on sibling failure)
  • Returns a promise that resolves to an object with all task results as { status: 'fulfilled', value } or { status: 'rejected', reason }
  • Never rejects - failed tasks are included in the result (like Promise.allSettled)
  • If a task depends on a failed task, the dependent task will also fail unless it catches the error

flow<R>(tasks, options?)

Execute tasks with automatic dependency resolution and early exit support.

  • Type parameter <R>: Required. Specifies the return type that $end() must accept
  • tasks: Object of async task functions
  • options: Same as all() - optional configuration object
  • Each task function receives:
    • this.$ - an object with promises for all task results
    • this.$signal - an AbortSignal for resource cleanup
    • this.$end(value: R) - function to exit the entire flow early with a return value of type R
  • Returns a promise that resolves to R | undefined
    • Returns the value passed to the first $end() call
    • Returns undefined if no task calls $end()
  • See Early Exit Flow for detailed usage

Examples

Basic Parallel Execution

const { a, b, c } = await all({
  async a() { await sleep(1000); return 1 },
  async b() { await sleep(1000); return 2 },
  async c() { await sleep(1000); return 3 }
})

// All three run in parallel
// Returns { a: 1, b: 2, c: 3 }

With Dependencies

const { user, profile, settings } = await all({
  async user() { return fetchUser(1) },
  async profile() { return fetchProfile((await this.$.user).id) },
  async settings() { return fetchSettings((await this.$.user).id) }
})

// User runs first, then profile and settings run in parallel

Type Safety

Full TypeScript support with automatic type inference:

const result = await all({
  async num() { return 42 },
  async str() { return 'hello' },
  async combined() {
    const n = await this.$.num  // n: number (auto-inferred!)
    const s = await this.$.str  // s: string (auto-inferred!)
    return `${s}: ${n}`
  }
})

result.num       // number
result.str       // string
result.combined  // string

Complex Dependency Graph

const { a, b, c, d, e } = await all({
  async a() { return 1 },
  async b() { return 2 },
  async c() { return (await this.$.a) + 10 },
  async d() { return (await this.$.b) + 20 },
  async e() { return (await this.$.c) + (await this.$.d) }
})

// a and b run in parallel
// c waits for a, d waits for b (c and d can overlap)
// e waits for both c and d

// { a: 1, b: 2, c: 11, d: 22, e: 33 }
console.log({ a, b, c, d, e })

Stepped Dependency Chain

In this example, the postsWithAuthor task calls await this.$.user and await this.$.posts sequentially but there won't be any actual delays. The all function will always kick off all tasks as early as possible, so posts was already running while we awaited this.$.user:

const result = await all({
  async user() {
    return fetchUser(1)
  },
  async posts() {
    return fetchPosts((await this.$.user).id)
  },
  async postsWithAuthor() {
    const user = await this.$.user
    console.log(`Fetched user: ${user.name}`)
    const posts = await this.$.posts
    return posts.map(post => ({ ...post, author: user.name }))
  },
})

This still gives optimal parallelization.

Debug Mode

Enable debug mode to visualize task execution with a waterfall chart:

const result = await all({
  async config() {
    await sleep(50)
    return { apiUrl: 'https://api.example.com' }
  },
  async user() {
    await sleep(120)
    return { id: 1, name: 'Alice' }
  },
  async posts() {
    const user = await this.$.user
    await sleep(200)
    return fetchPosts(user.id)
  },
  async profile() {
    const user = await this.$.user
    const config = await this.$.config
    await sleep(80)
    return fetchProfile(user.id, config.apiUrl)
  },
  async analytics() {
    const posts = await this.$.posts
    const profile = await this.$.profile
    await sleep(40)
    return computeAnalytics(posts, profile)
  }
}, { debug: true })

This outputs an ASCII waterfall chart showing:

  • Task execution timeline
  • Task duration in milliseconds
  • Dependencies for each task
  • Visual representation of parallel vs sequential execution

Example output:

╔════════════════════════════════════════════════════════════════════════════════╗
║                           Task Execution Waterfall                             ║
╠════════════════════════════════════════════════════════════════════════════════╣
║ Total Duration: 364.54ms                                                       ║
╚════════════════════════════════════════════════════════════════════════════════╝

Task      │ Deps           │ Duration │ Timeline
──────────┼────────────────┼──────────┼──────────────────────────────────────────────────────────────────
config    │ -              │   51.4ms │ ████████                                                         
user      │ -              │  121.4ms │ ████████████████████                                             
posts     │ user           │  322.6ms │ ░░░░░░░░░░░░░░░░░░░░██████████████████████████████████████       
profile   │ user, config   │  202.9ms │ ░░░░░░░░░░░░░░░░░░░░███████████████████                          
analytics │ posts, profile │  364.4ms │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░███████

Legend: █ = active (fulfilled), ▓ = active (rejected), ░ = waiting on dependency

The enhanced waterfall visualization shows:

  • (solid bars) = Active execution time when the task is running its code
  • (light shade) = Waiting time when the task is blocked on a dependency
  • (dashed bars) = Active execution for tasks that failed

This makes it easy to:

  • Distinguish between active execution vs waiting on dependencies
  • Identify which tasks are running in parallel
  • See exactly how long each task actively executes vs waits
  • Understand the dependency chain and blocking relationships
  • Spot opportunities for optimization (e.g., tasks with long wait times)

Error Handling

With all()

Errors propagate to dependent tasks automatically, similar to Promise.all:

try {
  await all({
    async a() { throw new Error('Failed') },
    async b() { return (await this.$.a) + 1 }
  })
} catch (err) {
  console.error(err) // Error: Failed
}

With allSettled()

All tasks complete and return their settled state, never rejecting:

const result = await allSettled({
  async a() { return 1 },
  async b() { throw new Error('Task b failed') },
  async c() { return 3 }
})

// result.a: { status: 'fulfilled', value: 1 }
// result.b: { status: 'rejected', reason: Error('Task b failed') }
// result.c: { status: 'fulfilled', value: 3 }

if (result.a.status === 'fulfilled') {
  console.log(result.a.value) // 1
}

if (result.b.status === 'rejected') {
  console.error(result.b.reason) // Error: Task b failed
}

Handling Dependency Failures with allSettled()

When a task depends on a failed task, it will also fail unless the error is caught:

const result = await allSettled({
  async a() { throw new Error('a failed') },
  async b() {
    // This will fail because 'a' failed
    const aValue = await this.$.a
    return aValue + 10
  },
  async c() {
    // This handles the error and succeeds
    try {
      const aValue = await this.$.a
      return aValue + 10
    } catch (err) {
      return 'fallback value'
    }
  }
})

// result.a: { status: 'rejected', reason: Error('a failed') }
// result.b: { status: 'rejected', reason: Error('a failed') }
// result.c: { status: 'fulfilled', value: 'fallback value' }

Abort Signal

When a task fails in all(), you may want to cancel other running tasks to avoid wasting resources (e.g., API calls, LLM requests).

Each task receives this.$signal - an AbortSignal that gets aborted when any sibling task fails:

const result = await all({
  async fetchUser() {
    const res = await fetch('/api/user', { signal: this.$signal })
    return res.json()
  },
  async fetchPosts() {
    // If fetchUser fails, this.$signal will be aborted
    const res = await fetch('/api/posts', { signal: this.$signal })
    return res.json()
  }
})

You can also pass an external signal to respect parent abort controllers:

const controller = new AbortController()

const result = await all({
  async a() { return fetchData(this.$signal) },
  async b() { return fetchMoreData(this.$signal) }
}, { signal: controller.signal })

Note: allSettled() does NOT auto-abort on task failure (to preserve its "wait for all" behavior), but external signal abort still works.

Early Exit Flow

flow allows you to exit early from complex async flows when a task determines the final result. This is useful for optimization patterns like:

  • Cache checks: Exit early if cached data is available
  • Racing operations: Return the first successful result
  • Conditional computations: Skip remaining work based on intermediate results

flow(tasks, options?)

Execute tasks with automatic dependency resolution, but allow any task to end the entire flow early by calling this.$end(value).

Key behaviors:

  • All tasks start together in parallel (same as all())
  • First task to call this.$end(value) determines the return value
  • After $end() is called, other tasks that try to access dependencies will receive errors (caught silently)
  • Real errors (not from $end) still propagate to the caller
  • Integrates with this.$signal for resource cleanup

Early Exit from Cache

import { flow } from 'better-all'

const data = await flow<YourDataType>({
  async checkCache() {
    const cached = await getFromCache('key')
    if (cached) this.$end(cached)  // Exit early with cached data
    return null
  },
  async fetchFromApi() {
    const user = await this.$.checkCache  // Will throw if cache hit
    return await fetchExpensiveData()
  },
  async processData() {
    const apiData = await this.$.fetchFromApi
    this.$end(transform(apiData))
  }
})

Racing Operations

const result = await flow<ResponseData>({
  async fetchFromPrimary() {
    await sleep(100)
    const data = await fetch('/api/primary')
    this.$end(await data.json())
  },
  async fetchFromBackup() {
    await sleep(500)
    const data = await fetch('/api/backup')
    this.$end(await data.json())
  }
})
// Returns data from whichever endpoint responds first

Conditional Early Exit

const result = await flow<{ error: string } | ProcessedData>({
  async validateInput() {
    const isValid = await validate(input)
    if (!isValid) this.$end({ error: 'Invalid input' })
    return input
  },
  async processData() {
    const validInput = await this.$.validateInput
    const processed = await heavyComputation(validInput)
    this.$end({ success: true, data: processed })
  }
})

Return Type

You must specify the return type as a type parameter to flow:

const result = await flow<number | string>({
  async task1() {
    this.$end(42)  // number
    return 1
  },
  async task2() {
    this.$end('hello')  // string
    return 'world'
  }
})
// result: number | string | undefined

To explicitly allow undefined as a return value:

// Explicitly allow undefined
const result = await flow<string | undefined>({
  async task1() {
    const data = await getData()
    if (!data) this.$end(undefined)  // ✅ OK: undefined is in the type parameter
    this.$end(data)
  }
})
// result: string | undefined

⚠️ Important Notes:

  • The type parameter <R> is required and specifies what type $end() accepts
  • If you want to call $end(undefined), you must explicitly include undefined in R (e.g., flow<undefined> or flow<string | undefined>)
  • If no task calls this.$end(), the flow will return undefined
  • Once $end() is called, subsequent dependency accesses will fail (but are caught silently)
  • $end() stops the current task execution (throws internally)

Development

pnpm install     # Install dependencies
pnpm test        # Run tests
pnpm build       # Build

Author

Shu Ding

License

MIT

About

Better Promise.all with automatic dependency optimization

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors