diff --git a/README.md b/README.md
index dcc1cbf..2559e94 100644
--- a/README.md
+++ b/README.md
@@ -385,7 +385,7 @@ This is what has been implemented so far, is planned or skipped:
| ✅ [#258][] | `windowed` | `windowed` | | |
| ✅ [#2][] | `zip` | `zip` | | |
| ✅ | `zip3` | `zip3` | | |
-| | | `zip4` | | |
+| ✅ | | `zip4` | | |
¹⁾ _These functions require a form of pre-materializing through `TaskSeq.cache`, similar to the approach taken in the corresponding `Seq` functions. It doesn't make much sense to have a cached async sequence. However, `AsyncSeq` does implement these, so we'll probably do so eventually as well._
diff --git a/release-notes.txt b/release-notes.txt
index 5c0b97e..e029cee 100644
--- a/release-notes.txt
+++ b/release-notes.txt
@@ -2,6 +2,7 @@
Release notes:
0.6.0
+ - adds TaskSeq.zip4
- adds TaskSeq.scan and TaskSeq.scanAsync, #289
- adds TaskSeq.pairwise, #289
- adds TaskSeq.groupBy and TaskSeq.groupByAsync, #289
diff --git a/src/FSharp.Control.TaskSeq.Test/TaskSeq.Zip.Tests.fs b/src/FSharp.Control.TaskSeq.Test/TaskSeq.Zip.Tests.fs
index 67db6a4..5e31dfa 100644
--- a/src/FSharp.Control.TaskSeq.Test/TaskSeq.Zip.Tests.fs
+++ b/src/FSharp.Control.TaskSeq.Test/TaskSeq.Zip.Tests.fs
@@ -304,3 +304,132 @@ module SideEffectsZip3 =
combined |> should haveLength 10
}
+
+//
+// TaskSeq.zip4
+//
+
+module EmptySeqZip4 =
+ []
+ let ``Null source is invalid for zip4`` () =
+ assertNullArg
+ <| fun () -> TaskSeq.zip4 null TaskSeq.empty TaskSeq.empty TaskSeq.empty
+
+ assertNullArg
+ <| fun () -> TaskSeq.zip4 TaskSeq.empty null TaskSeq.empty TaskSeq.empty
+
+ assertNullArg
+ <| fun () -> TaskSeq.zip4 TaskSeq.empty TaskSeq.empty null TaskSeq.empty
+
+ assertNullArg
+ <| fun () -> TaskSeq.zip4 TaskSeq.empty TaskSeq.empty TaskSeq.empty null
+
+ assertNullArg <| fun () -> TaskSeq.zip4 null null null null
+
+ [)>]
+ let ``TaskSeq-zip4 can zip empty sequences`` variant =
+ TaskSeq.zip4 (Gen.getEmptyVariant variant) (Gen.getEmptyVariant variant) (Gen.getEmptyVariant variant) (Gen.getEmptyVariant variant)
+ |> verifyEmpty
+
+ [)>]
+ let ``TaskSeq-zip4 stops at first exhausted sequence`` variant =
+ // remaining sequences are non-empty but first is empty → result is empty
+ TaskSeq.zip4 (Gen.getEmptyVariant variant) (taskSeq { yield 1 }) (taskSeq { yield 2 }) (taskSeq { yield 3 })
+ |> verifyEmpty
+
+ [)>]
+ let ``TaskSeq-zip4 stops when second sequence is empty`` variant =
+ TaskSeq.zip4 (taskSeq { yield 1 }) (Gen.getEmptyVariant variant) (taskSeq { yield 2 }) (taskSeq { yield 3 })
+ |> verifyEmpty
+
+ [)>]
+ let ``TaskSeq-zip4 stops when third sequence is empty`` variant =
+ TaskSeq.zip4 (taskSeq { yield 1 }) (taskSeq { yield 2 }) (Gen.getEmptyVariant variant) (taskSeq { yield 3 })
+ |> verifyEmpty
+
+ [)>]
+ let ``TaskSeq-zip4 stops when fourth sequence is empty`` variant =
+ TaskSeq.zip4 (taskSeq { yield 1 }) (taskSeq { yield 2 }) (taskSeq { yield 3 }) (Gen.getEmptyVariant variant)
+ |> verifyEmpty
+
+module ImmutableZip4 =
+ [)>]
+ let ``TaskSeq-zip4 zips in correct order`` variant = task {
+ let one = Gen.getSeqImmutable variant
+ let two = Gen.getSeqImmutable variant
+ let three = Gen.getSeqImmutable variant
+ let four = Gen.getSeqImmutable variant
+ let! combined = TaskSeq.zip4 one two three four |> TaskSeq.toArrayAsync
+
+ combined |> should haveLength 10
+
+ combined
+ |> should equal (Array.init 10 (fun x -> x + 1, x + 1, x + 1, x + 1))
+ }
+
+ []
+ let ``TaskSeq-zip4 produces correct 4-tuples with mixed types`` () = task {
+ let one = taskSeq {
+ yield "a"
+ yield "b"
+ }
+
+ let two = taskSeq {
+ yield 1
+ yield 2
+ }
+
+ let three = taskSeq {
+ yield true
+ yield false
+ }
+
+ let four = taskSeq {
+ yield 1.0
+ yield 2.0
+ }
+
+ let! combined = TaskSeq.zip4 one two three four |> TaskSeq.toArrayAsync
+
+ combined
+ |> should equal [| ("a", 1, true, 1.0); ("b", 2, false, 2.0) |]
+ }
+
+ []
+ let ``TaskSeq-zip4 truncates to shortest sequence`` () = task {
+ let one = taskSeq { yield! [ 1..10 ] }
+ let two = taskSeq { yield! [ 1..5 ] }
+ let three = taskSeq { yield! [ 1..3 ] }
+ let four = taskSeq { yield! [ 1..7 ] }
+ let! combined = TaskSeq.zip4 one two three four |> TaskSeq.toArrayAsync
+
+ combined |> should haveLength 3
+
+ combined
+ |> should equal [| (1, 1, 1, 1); (2, 2, 2, 2); (3, 3, 3, 3) |]
+ }
+
+ []
+ let ``TaskSeq-zip4 works with single-element sequences`` () = task {
+ let! combined =
+ TaskSeq.zip4 (TaskSeq.singleton 1) (TaskSeq.singleton "x") (TaskSeq.singleton true) (TaskSeq.singleton 42L)
+ |> TaskSeq.toArrayAsync
+
+ combined |> should equal [| (1, "x", true, 42L) |]
+ }
+
+module SideEffectsZip4 =
+ [)>]
+ let ``TaskSeq-zip4 can deal with side effects in sequences`` variant = task {
+ let one = Gen.getSeqWithSideEffect variant
+ let two = Gen.getSeqWithSideEffect variant
+ let three = Gen.getSeqWithSideEffect variant
+ let four = Gen.getSeqWithSideEffect variant
+ let! combined = TaskSeq.zip4 one two three four |> TaskSeq.toArrayAsync
+
+ combined
+ |> Array.forall (fun (x, y, z, w) -> x = y && y = z && z = w)
+ |> should be True
+
+ combined |> should haveLength 10
+ }
diff --git a/src/FSharp.Control.TaskSeq/TaskSeq.fs b/src/FSharp.Control.TaskSeq/TaskSeq.fs
index aced3a5..a1fd555 100644
--- a/src/FSharp.Control.TaskSeq/TaskSeq.fs
+++ b/src/FSharp.Control.TaskSeq/TaskSeq.fs
@@ -512,6 +512,7 @@ type TaskSeq private () =
static member zip source1 source2 = Internal.zip source1 source2
static member zip3 source1 source2 source3 = Internal.zip3 source1 source2 source3
+ static member zip4 source1 source2 source3 source4 = Internal.zip4 source1 source2 source3 source4
static member fold folder state source = Internal.fold (FolderAction folder) state source
static member foldAsync folder state source = Internal.fold (AsyncFolderAction folder) state source
static member scan folder state source = Internal.scan (FolderAction folder) state source
diff --git a/src/FSharp.Control.TaskSeq/TaskSeq.fsi b/src/FSharp.Control.TaskSeq/TaskSeq.fsi
index fcf45cf..d92bdb0 100644
--- a/src/FSharp.Control.TaskSeq/TaskSeq.fsi
+++ b/src/FSharp.Control.TaskSeq/TaskSeq.fsi
@@ -1554,6 +1554,24 @@ type TaskSeq =
static member zip3:
source1: TaskSeq<'T1> -> source2: TaskSeq<'T2> -> source3: TaskSeq<'T3> -> TaskSeq<'T1 * 'T2 * 'T3>
+ ///
+ /// Combines the four task sequences into a new task sequence of 4-tuples. The four sequences need not have equal lengths:
+ /// when one sequence is exhausted any remaining elements in the other sequences are ignored.
+ ///
+ ///
+ /// The first input task sequence.
+ /// The second input task sequence.
+ /// The third input task sequence.
+ /// The fourth input task sequence.
+ /// The result task sequence of 4-tuples.
+ /// Thrown when any of the four input task sequences is null.
+ static member zip4:
+ source1: TaskSeq<'T1> ->
+ source2: TaskSeq<'T2> ->
+ source3: TaskSeq<'T3> ->
+ source4: TaskSeq<'T4> ->
+ TaskSeq<'T1 * 'T2 * 'T3 * 'T4>
+
///
/// argument of type through the computation. If the input function is and the elements are
/// then computes.
diff --git a/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs b/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs
index 26b02a6..66363e0 100644
--- a/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs
+++ b/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs
@@ -585,6 +585,33 @@ module internal TaskSeqInternal =
go <- step1 && step2 && step3
}
+ let zip4 (source1: TaskSeq<_>) (source2: TaskSeq<_>) (source3: TaskSeq<_>) (source4: TaskSeq<_>) =
+ checkNonNull (nameof source1) source1
+ checkNonNull (nameof source2) source2
+ checkNonNull (nameof source3) source3
+ checkNonNull (nameof source4) source4
+
+ taskSeq {
+ use e1 = source1.GetAsyncEnumerator CancellationToken.None
+ use e2 = source2.GetAsyncEnumerator CancellationToken.None
+ use e3 = source3.GetAsyncEnumerator CancellationToken.None
+ use e4 = source4.GetAsyncEnumerator CancellationToken.None
+ let mutable go = true
+ let! step1 = e1.MoveNextAsync()
+ let! step2 = e2.MoveNextAsync()
+ let! step3 = e3.MoveNextAsync()
+ let! step4 = e4.MoveNextAsync()
+ go <- step1 && step2 && step3 && step4
+
+ while go do
+ yield e1.Current, e2.Current, e3.Current, e4.Current
+ let! step1 = e1.MoveNextAsync()
+ let! step2 = e2.MoveNextAsync()
+ let! step3 = e3.MoveNextAsync()
+ let! step4 = e4.MoveNextAsync()
+ go <- step1 && step2 && step3 && step4
+ }
+
let collect (binder: _ -> #IAsyncEnumerable<_>) (source: TaskSeq<_>) =
checkNonNull (nameof source) source