Skip to content

feat(fjl): #121-generators add lazy replicateIter/unfoldrIter siblings - #130

Open
elycruz wants to merge 1 commit into
mainfrom
121-generators
Open

feat(fjl): #121-generators add lazy replicateIter/unfoldrIter siblings#130
elycruz wants to merge 1 commit into
mainfrom
121-generators

Conversation

@elycruz

@elycruz elycruz commented Jul 31, 2026

Copy link
Copy Markdown
Member

Summary

Finishes the iterator → generator tail for list/. replicate and unfoldr
were the two remaining eager producers; both now have lazy *Iter siblings, and
the scan*/zip*/unzip* families have been audited with the verdicts recorded
in md/issues/GENERATOR_AUDIT.md.

No existing signature or return type changed. Per #121's own rule — "where a
generator would break existing consumers, keep the eager version and add a
*-suffixed generator sibling rather than changing the return type"
— both
conversions took the sibling route.

Closes #121
Contributes to #116

Work-unit id: 121-generators

Naming

* isn't a legal identifier character, so "*-suffixed" is realized as the
repo's existing convention: range.ts already ships eager range next to the
generator rangeIter, the only eager/lazy sibling pair in the codebase. New
pairs follow it — replicateIter, unfoldrIter, $replicateIter,
$unfoldrIter.

rangeIter carries an @todo Rename to 'range', so the eventual v2 intent is
generator-primary naming. That rename should move range, replicate, and
unfoldr together as one deliberate breaking change, not one function at a time.

Yield shape

cycle/iterate/repeat don't yield elements — they yield the list built so
far
, growing by one per iteration (Generator<T[], void>). The new siblings
follow that exactly, which gives a law that both test files assert:

The last value a *Iter generator yields is deep-equal to what its eager
counterpart returns.

As in iterate/repeat, the yielded array is the same growing reference each
time; callers wanting a snapshot must slice it. Documented on both functions.

Conversion decisions

replicate → keep eager, add replicateIter

replicate is bounded by construction. n is finite, so there is nothing to
stop early from and no non-termination to avoid — the motivation that justifies
laziness elsewhere simply doesn't apply. Converting the return type outright
would break every consumer that indexes or .lengths the result, in exchange for
nothing.

The sibling is still worth shipping: replicate is the bounded form of repeat,
and replicateIter(Infinity, x) degenerates to repeat(x) — something eager
replicate cannot express at all, since Array(Infinity) throws. It also
composes with the generator-friendly take/takeWhile from #87.

Eager replicate keeps its Array(n).fill(...) fast path; a generator is not a
faster way to fill a fixed-size array.

unfoldr → keep eager, add unfoldrIter

Laziness is a real fix here, not a nicety: an op that never returns
undefined makes eager unfoldr loop forever, and unfoldrIter makes that same
op consumable.

But unfoldr has a live in-repo array consumer — src/list/unzipN.ts uses its
result as a T[][] seed for foldl — plus tests/object/index_test.ts. Changing
the return type is a breaking change with an in-tree casualty, so: sibling.

Eager unfoldr is now implemented in terms of unfoldrIter (it drains the
generator and returns the last yield), so the two cannot drift apart. Behaviour is
identical, including the accumulated-list third argument handed to op, which now
has its own regression test.

UnfoldrOp @todo (#116)

@todo Letters should be flipped in initial type;  E.g., UnfoldrOp<B, A>;

Resolved as "do not flip", and replaced with an @note recording why:

  1. UnfoldrOp<A, B> matches unfoldr<A, B>'s own generic order. Flipping the
    type but not the function creates a mismatch; flipping both is a public API
    break for zero benefit.
  2. It matches the op's return tuple, [A, B].
  3. The premise — "generics should follow parameter order" — isn't the convention
    in this module. ScanlOp<A, B> = (b: B, a: A, ...) => B has exactly the same
    shape: its first generic is not its first parameter. Flipping UnfoldrOp
    alone would make it the odd one out.

Generics here are ordered by role (element type first, seed/accumulator
second), not by parameter position. The @todo is gone either way, as required.

Scan/zip audit — documentation only, no scan/zip code touched

Function Verdict Rationale
scanl Stay eager; lazy sibling deferred Genuinely streamable (result i depends only on inputs 0..i), but input is an already-materialized A[], so a sibling buys nothing until list fns accept Iterable.
scanl1 Stay eager; deferred with scanl Thin wrapper over scanl.
scanr Stay eager — permanently Right-to-left: the first result needs the last input, so no output exists before the whole input is consumed. A generator would do 100% of its work before the first next() — a misleading laziness contract.
scanr1 Stay eager — permanently Wraps scanr; also calls last/init, both whole-list ops.
zip Stay eager; deferred Streamable in principle, but every path goes through toShortest, which measures both lists up front, and both params are typed T[].
zip3, zip4, zipN Stay eager; deferred with zip All delegate to zipN/toShortest.
zipWith, zipWith3, zipWithN Stay eager; deferred with zip Same shape as zip, op applied per pair.
unzip Stay eager — permanently Returns a tuple of lists, not a list. Nothing to yield; emitting the second output lazily would buffer the whole input anyway.
unzipN Stay eager — permanently Same as unzip, generalized; folds over the entire input in one pass.

Everything marked "deferred" is blocked on the same prerequisite: list functions
accepting Iterable<T> instead of T[]
. take/takeWhile already do (#87);
scanl and zip* do not. Shipping scanlIter/zipIter today would deliver the
lazy half of a pipeline whose eager half still forces the whole input. That
widening touches toShortest/reduce/length and deserves its own issue.

Incidental findings — recorded, not fixed

Found while auditing; all pre-existing and out of scope for a laziness ticket.
Verified by running the functions:

Finding Detail
scanr's doc-comment is wrong Claims head(scanr(fn, z, xs)) === foldr(fn, z, xs). Actual: scanr(add, 0, [1,2,3])[3,5,6], foldr6. It's last, not head — the impl pushes right-to-left, so output is reversed vs. Haskell.
scanl omits the seed Haskell scanl (+) 0 [1,2,3] = [0,1,3,6]; fjl returns [1,3,6]. Doc-comment reproduces the Haskell form. (last(scanl f z xs) === foldl f z xs does hold.)
scanl1 drops the head Haskell = [1,3,6]; fjl returns [3,6].
scanr1 drops the last fjl returns [5,6], symmetric to scanl1.

These four are one decision, not four: either scan* emits the seed (Haskell
semantics, breaking) or the doc-comments are corrected to describe the code
(non-breaking). Belongs with #122's per-module implementation review.

Testing

  • pnpm test133 suites, 1083 tests, all passing (baseline on main:
    133 / 1066 — +17 tests, no suite added since both test files already existed).
  • pnpm build → exits 0.
  • npx eslint on all four changed source/test files → clean, no new errors or
    warnings. (Repo-wide pnpm lint still fails on pre-existing issues, unchanged.)
  • No hooks bypassed — pre-commit, commit-msg, and pre-push all ran clean.

Beyond value correctness, the new tests assert the laziness property that
motivated the change
, not just outputs:

  • replicateIter(Infinity, 'x') yields prefixes and stays done: false — a
    non-lazy implementation would never return, and eager replicate throws here.
  • unfoldrIter calls op exactly once per next(), and zero times before the
    first next()
    (asserted with a call counter).
  • unfoldrIter with a never-terminating op (x => [x, x + 1]) is consumed and
    broken out of — the exact case that hangs eager unfoldr.
  • Both siblings' last yield is asserted equal to their eager counterpart's return.
  • The accumulated-list third argument passed to unfoldr's op is now covered.

Breaking changes

None, by design. Every existing export keeps its signature and return type:
replicate, $replicate, unfoldr, $unfoldr, and UnfoldrOp are unchanged as
public API. Four new exports are added (replicateIter, $replicateIter,
unfoldrIter, $unfoldrIter), picked up automatically by list/index.ts's
existing export *. unfoldr's internals were rewritten to delegate to the
generator, with behaviour held identical and covered by tests.

🤖 Generated with Claude Code

Adds generator siblings for the two remaining eager `list/` producers, per
#121's rule that a generator which would break existing consumers ships as a
sibling rather than as a changed return type. Both follow the established
`cycle`/`iterate`/`repeat` shape (yield the growing list, `Generator<T[], void>`)
and the `range`/`rangeIter` naming precedent.

- `replicateIter` / `$replicateIter` - `replicate` stays eager (bounded by `n`,
  so laziness buys no correctness); the sibling exists because it is the bounded
  form of `repeat` and `replicateIter(Infinity, x)` is expressible where
  `replicate` throws.
- `unfoldrIter` / `$unfoldrIter` - `unfoldr` stays eager because `unzipN` and
  `tests/object/index_test.ts` consume its `A[]`; the sibling makes `op`s that
  never return `undefined` (which hang the eager version) consumable. Eager
  `unfoldr` is now implemented by draining the generator, so the two cannot
  drift.
- Resolves `UnfoldrOp`'s `@todo` (part of #116) by deciding *not* to flip the
  generics and documenting why: `<A, B>` is ordered by role, matching
  `unfoldr<A, B>`, the op's return tuple, and `ScanlOp`/`ScanrOp`.
- Documents the `scan*`/`zip*`/`unzip*` audit in `md/issues/GENERATOR_AUDIT.md`:
  `scanr*` and `unzip*` stay eager permanently, `scanl*`/`zip*` are deferred
  behind `Iterable` inputs. No scan/zip code changed.

No breaking changes - every existing signature and return type is untouched.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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.

v2.0 - feature: Update iterator family of methods to just be generators, where it makes sense

1 participant