Skip to content

feat: add TaskSeq.foldWhile and foldWhileAsync#401

Open
OnurGumus wants to merge 4 commits intofsprojects:mainfrom
OnurGumus:add-foldUntil
Open

feat: add TaskSeq.foldWhile and foldWhileAsync#401
OnurGumus wants to merge 4 commits intofsprojects:mainfrom
OnurGumus:add-foldUntil

Conversation

@OnurGumus
Copy link
Copy Markdown

@OnurGumus OnurGumus commented Apr 21, 2026

Summary

Adds TaskSeq.foldWhile and TaskSeq.foldWhileAsync — a fold variant with early termination. A boolean predicate decides, against the current state and next element, whether that element should be folded in. Once the predicate returns false, iteration halts without folding that element and no further elements are pulled from the input (matches takeWhile semantics exactly).

TaskSeq.foldWhile      : ('State -> 'T ->       bool) -> ('State -> 'T ->       'State) -> 'State -> TaskSeq<'T> -> Task<'State>
TaskSeq.foldWhileAsync : ('State -> 'T -> #Task<bool>) -> ('State -> 'T -> #Task<'State>) -> 'State -> TaskSeq<'T> -> Task<'State>

Motivation

Reaching for fold / foldAsync often ends with a mutable flag or a match state with Decided -> state | _ -> ... guard at the top of the folder when the caller wants to short-circuit on some condition discovered mid-stream. tryPickAsync handles the "find and halt" subcase but cannot also thread an accumulator. foldWhile fills the gap.

// Sum elements while the running total stays within a budget.
[ 1; 2; 3; 4; 5 ]
|> TaskSeq.ofList
|> TaskSeq.foldWhile
    (fun acc x -> acc + x <= 5)
    (+)
    0
// result: 3  (1+2, then 3 would overshoot — halt without folding it)

Why not existing operators

  • tryPickAsync — short-circuits but returns 'U option, no accumulator.
  • fold + mutable flag — works but requires mutation at the call site and drains the whole sequence even after deciding.
  • takeWhileInclusive + toListAsync — nice shape but allocates a list and requires a second pass to inspect.
  • scan + tryFind — loses the final state when no element halts.

foldWhile is the shape the language naturally wants for stateful early-exit folds, and matches the existing takeWhile / skipWhile / tryFind convention: plain bool predicate separated from the data function, no new public types.

What's in the PR

  • TaskSeq.foldWhile and TaskSeq.foldWhileAsync in TaskSeq.fs + signatures and XML docs in TaskSeq.fsi
  • Release-notes entry under Unreleased
  • 22 tests in TaskSeq.FoldWhile.Tests.fs:
    • Null source raises ArgumentNullException
    • Empty sequence → initial state returned, predicate and folder never called (all variants from TestEmptyVariants)
    • Always-true predicate behaves identically to fold (including left-associativity)
    • Predicate false on first element → predicate called once, folder never called, initial state returned
    • Halt mid-sequence → correct halt state preserved, source not pulled past the halting element, predicate/folder call counts verified
    • Non-halting traversal equivalent to fold

Test plan

  • dotnet test ... — full suite: 5277 passed, 0 failed, 2 skipped
  • Filtered foldWhile suite: 44 passed, 0 failed
  • dotnet fantomas . --check — clean
  • Library builds on netstandard2.1

Originally proposed as foldUntil with a FoldStep<'State> DU (Continue / Halt). Per @dsyme's review, refactored to the bool-predicate form above to match the library's existing early-termination API style.

Fold over a task sequence with early termination. The folder returns
`Continue newState` to keep consuming, or `Halt newState` to stop
immediately; in either case the state is updated. No elements past the
one that caused Halt are enumerated from the input.

Motivation: reaching for `fold`/`foldAsync` often ends with a mutable
flag or a `match state with Decided -> state | _ -> ...` guard at the
top of the folder when the caller wants to short-circuit on some
condition discovered mid-stream. `tryPickAsync` handles the "find and
halt" subcase but cannot also thread an accumulator. `foldUntil` fills
the gap and removes the impedance mismatch.

Includes:
  - FoldStep<'State> DU (Continue / Halt) in public API
  - Internal FoldUntilAction DU unifying sync/async folder dispatch
  - Public TaskSeq.foldUntil and TaskSeq.foldUntilAsync
  - XML docs on both overloads mirroring fold/foldAsync style
  - Release-notes entry
  - 22 tests covering: null source, empty sequence (no folder call,
    initial state preserved), all-Continue (equals fold), Halt-on-first
    (1 folder call, no further pulls), Halt mid-sequence (correct count,
    source not pulled past Halt), Halt-on-last (equals fold)
@dsyme
Copy link
Copy Markdown
Contributor

dsyme commented Apr 21, 2026

@OnurGumus I'm wondering about the new type. Why not a boolean flag to indicate when to stop?

TaskSeq.foldUntil      : ('State -> 'T ->   'State * bool) -> 'State -> TaskSeq<'T> -> Task<'State>

Reworks the early-termination fold added in 7bde226 to match the
conventions of FSharp.Control.TaskSeq: bool-returning predicate + plain
folder, and no new public DU. Every other early-termination API in the
library (takeWhile, skipWhile, tryFind, forall, exists) uses this
shape; every DU in the source is internal. FoldStep<'State> would have
been the only public DU, which is the inconsistency @dsyme flagged on
the PR.

API:
  - TaskSeq.foldWhile      : ('State -> 'T -> bool)
                           -> ('State -> 'T -> 'State)
                           -> 'State -> TaskSeq<'T> -> Task<'State>
  - TaskSeq.foldWhileAsync : ('State -> 'T -> #Task<bool>)
                           -> ('State -> 'T -> #Task<'State>)
                           -> 'State -> TaskSeq<'T> -> Task<'State>

Semantics (match takeWhile, exclusive): the predicate is evaluated
against (currentState, nextElement) before that element is folded. If
false, iteration halts without folding that element and the source is
not enumerated further.

Removes:
  - public FoldStep<'State> DU
  - internal FoldUntilAction DU
  - TaskSeq.foldUntil / foldUntilAsync

Tests: rewritten for exclusive semantics (halting element not folded,
predicate-call / folder-call counts asserted separately).
Full test suite green (5277 passed).
@OnurGumus
Copy link
Copy Markdown
Author

@OnurGumus I'm wondering about the new type. Why not a boolean flag to indicate when to stop?

TaskSeq.foldUntil      : ('State -> 'T ->   'State * bool) -> 'State -> TaskSeq<'T> -> Task<'State>

Hi @dsyme , I kinda favor types over bools, but you are right. Looking at other signatures using bool would be more consistent. I refactored accordingly:

foldWhile : ('State -> 'T -> bool) -> ('State -> 'T -> 'State) -> 'State -> TaskSeq<'T> -> Task<'State> 

@OnurGumus OnurGumus changed the title feat: add TaskSeq.foldUntil and foldUntilAsync feat: add TaskSeq.foldWhile and foldWhileAsync Apr 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants