Go 1.27 generic methods quietly killed the thing I hated most about lo

44 views
Skip to first unread message

smal...@gmail.com

unread,
Jun 24, 2026, 9:16:30 PM (14 hours ago) Jun 24
to golang-nuts
TL;DR: Go 1.27's generic methods (golang/go#77273) finally make left-to-right,
chainable, lazy collection pipelines expressible. I built a library to explore
what falls out of that, and the most interesting part turned out to be where
generic methods *still* can't help — which draws a surprisingly clean line
through the whole API. Repo at the bottom; I'd genuinely like the list's view
on the design choices.


THE THING I HATED

Anyone who has used samber/lo has written something like this:

    sum := lo.Sum(
        lo.Map(
            lo.Filter(xs, func(x int, _ int) bool { return x%2 == 0 }),
            func(x int, _ int) int { return x * x },
        ),
    )


The logic is
filter -> map -> sum, but you read it sum(map(filter(...))):
inside-out, the reverse of the data flow. For years I assumed this was a taste
decision lo had made. It wasn't.

Before 1.27, a method could not declare its own type parameters. That means a
.Map() that turns Seq[int] into Seq[string] was not merely discouraged, it was
unrepresentable:

    // pre-1.27: does not compile. A method may not introduce [U any].
    func (s Seq[T]) Map[U any](f func(T) U) Seq[U]


Because methods couldn't carry type parameters, every library of this kind was
forced into top-level functions, and top-level functions compose inside-out.
The nesting wasn't a style; it was the language.


WHAT 1.27 UNLOCKS

golang/go#77273 (accepted and implemented in 1.27) lifts that restriction. The
same pipeline becomes:

    sum := From(xs).
        Filter(func(x int) bool { return x%2 == 0 }).
        SumBy(func(x int) int { return x * x })


Left-to-right, in data-flow order, and it autocompletes after the dot. It is
also lazy and short-circuiting, because it is built directly on the stdlib
iterators.


THE DESIGN, AND THE PART THAT SURPRISED ME

The core types are *defined types* over iter.Seq, not struct wrappers:

    type Seq[T any]     iter.Seq[T]      // func(yield func(T) bool)
    type Seq2[K, V any] iter.Seq2[K, V]


This gives zero-cost conversion in both directions: any
iter.Seq[T] is usable
as a Seq[T] and the result feeds straight into slices.Collect, maps.Keys, etc.
But it has a consequence I didn't anticipate.

Because Seq[T any] pins T to `any` at the type level, a method's own type
parameters are fresh and independent — they cannot add a constraint back onto
the receiver's T. So even *with* generic methods, you cannot write:

    func (s Seq[T]) Distinct() Seq[T]      // needs T comparable  -> impossible
    func (s Seq[T]) Max() (T, bool)        // needs cmp.Ordered   -> impossible

Anything that constrains T itself must remain a free function:

    func Distinct[T comparable](s Seq[T]) Seq[T]
    func Max[T cmp.Ordered](s Seq[T]) (T, bool)
    func Sum[T Numeric](s Seq[T]) T


But anything that only uses a method's *own* constrained parameter can be a
method (the escape hatch):

    func (s Seq[T]) GroupBy[K comparable](key func(T) K) map[K][]T
    func (s Seq[T]) Map[U any](f func(T) U) Seq[U]


So the whole API splits along one mechanically checkable question: does the
operation constrain T itself? If yes, free function; if no, method. Generic
methods solved most of the problem and drew a hard, principled line at the
rest. I found that line more interesting than the feature itself.


RECOVERING THE CHAIN

The annoying corollary: Distinct/Max/Sum being free functions drags you back to
inside-out reading, Sum(Distinct(xs)). The least-ugly fix I found is to pin the
constraint onto a subtype up front:

    type SeqNumeric[T Numeric] iter.Seq[T]
    func Numbers[T Numeric](s Seq[T]) SeqNumeric[T]


    // one entry function crosses the constraint boundary; then it's methods
    sum := Numbers(From(xs)).Distinct().Sum()


Inside
SeqNumeric the constraint is already satisfied, so Distinct/Sum become
methods again.
Numeric ⊂ Ordered ⊂ comparable lets you downgrade with
.Ordered()/.Comparable(). It works, but it is plainly a workaround for "a method
that adds a constraint to its receiver," which the type system cannot express.


HONEST LIMITATIONS

- Requgoog_1145710296ires Go 1.27 (currently go1.27rc1). The method chain depends on #77273,
  which is accepted and implemented but not yet in a stable release.
- Not evegoog_1145710297rything is lazy. Operations that must see the whole sequence (Sort,
  Reverse, TakeRight, sliding Window) materialize internally; they're isolated
  in one file and documented as such.
- Chunk/Window return iter.Seq[Seq[T]] rather than Seq[Seq[T]]: on 1.27rc1 a
  generic method on Seq[T] can't instantiate Seq[Seq[T]] (instantiation cycle,
  T := Seq[T]). I'd love to know if this is expected to relax in the stable
  release.
- gofmt on 1.27rc1 flags generic-method signature lines with "method must have
  no type parameters" — appears to be a formatter/parser lag; build, vet, and
  test all pass. Can anyone confirm this is a known rc issue?
- Out of scope by choice: error-handling chains (Seq2[T, error] short-circuit —
  I couldn't find a clean fit), parallelism, in-place mutation, arbitrary-depth
  flatten, tuples past Tuple4.


WHAT I'D LIKE FEEDBACK ON

1. Defined type over iter.Seq vs a struct wrapper — does the zero-cost interop
   justify pinning T to `any` and pushing constrained ops to free functions?
2. The method-vs-free-function rule above: would you have drawn it elsewhere?
3. Any cleaner idea than the subtype trick for restoring the chain on
   constrained operations?

Repo, with the full op set (~80:
Map/FlatMap/Scan/Zip/GroupBy/Window/set ops/
Seq2
), and a tasks/ directory containing the PRD and design rationale:

   
https://github.com/smallnest/seq

Thanks for reading — happy to be told I've gotten any of this wrong.

Ian Lance Taylor

unread,
Jun 24, 2026, 9:52:38 PM (14 hours ago) Jun 24
to smal...@gmail.com, golang-nuts
On Wed, Jun 24, 2026 at 6:17 PM smal...@gmail.com <smal...@gmail.com> wrote:
>
> WHAT I'D LIKE FEEDBACK ON
>
> 1. Defined type over iter.Seq vs a struct wrapper — does the zero-cost interop
> justify pinning T to `any` and pushing constrained ops to free functions?
> 2. The method-vs-free-function rule above: would you have drawn it elsewhere?
> 3. Any cleaner idea than the subtype trick for restoring the chain on
> constrained operations?

I think that is what you are after is what Merovious outlined at
https://github.com/golang/go/issues/65394#issuecomment-1933627587.

Ian

鸟窝

unread,
Jun 24, 2026, 9:59:57 PM (14 hours ago) Jun 24
to Ian Lance Taylor, golang-nuts
Thanks, I'll check it out. 

Ian Lance Taylor <ia...@golang.org> 于2026年6月25日周四 09:51写道:
Reply all
Reply to author
Forward
0 new messages