A fluent, type-safe collection library for Go, inspired by Laravel Collections.
Built with Go generics (1.18+) — no reflection, no any casts at call-sites.
go get github.com/hymns/go-collectionsimport collections "github.com/hymns/go-collections"
type User struct {
ID int
Name string
Age int
}
users := collections.New(
User{1, "Alice", 30},
User{2, "Bob", 20},
User{3, "Carol", 25},
)
// Filter → Pluck → Sort in one chain
names := collections.SortByString(
collections.Pluck(
users.Filter(func(u User) bool { return u.Age >= 25 }),
func(u User) string { return u.Name },
),
func(s string) string { return s },
)
names.Each(func(name string) {
fmt.Println(name) // Alice, Carol
})| Function | Description |
|---|---|
New(items...) |
Create from variadic arguments |
Collect(slice) |
Create from an existing slice |
c1 := collections.New(1, 2, 3)
c2 := collections.Collect([]string{"a", "b", "c"})| Method | Description |
|---|---|
All() []T |
Return all items as a slice |
Count() int |
Number of items |
IsEmpty() bool |
True if no items |
IsNotEmpty() bool |
True if at least one item |
First(fn?) (T, bool) |
First item, optionally matching a predicate |
Last(fn?) (T, bool) |
Last item, optionally matching a predicate |
Nth(n int) (T, bool) |
Item at index n (0-based) |
Contains(fn) bool |
True if any item satisfies fn |
Every(fn) bool |
True if all items satisfy fn |
Search(fn) (int, bool) |
Index of first item satisfying fn |
| Method | Description |
|---|---|
Filter(fn) *Collection[T] |
Keep items that satisfy fn |
Reject(fn) *Collection[T] |
Remove items that satisfy fn |
Each(fn) *Collection[T] |
Iterate (returns self for chaining) |
EachWithIndex(fn) *Collection[T] |
Iterate with index |
Reverse() *Collection[T] |
Reverse order |
Unique(keyFn) *Collection[T] |
Remove duplicates by key |
Sort(less) *Collection[T] |
Sort with a custom comparator |
Shuffle(randIntn) *Collection[T] |
Randomise order |
Chunk(size) []*Collection[T] |
Split into smaller collections |
| Method | Description |
|---|---|
Slice(offset, length) *Collection[T] |
Sub-collection from offset |
Skip(n) *Collection[T] |
Drop first n items |
Take(n) *Collection[T] |
Keep first n items |
TakeLast(n) *Collection[T] |
Keep last n items |
SkipUntil(fn) *Collection[T] |
Skip until predicate is true |
TakeUntil(fn) *Collection[T] |
Take until predicate is true |
| Method | Description |
|---|---|
Push(items...) *Collection[T] |
Append to end |
Prepend(items...) *Collection[T] |
Insert at start |
Pop() (T, bool) |
Remove and return last item |
Shift() (T, bool) |
Remove and return first item |
Merge(other) *Collection[T] |
Combine two collections |
Tap(fn) *Collection[T] |
Inspect without breaking the chain |
Clone() *Collection[T] |
Deep copy — independent backing slice |
| Method | Description |
|---|---|
ToJSON() ([]byte, error) |
Marshal to JSON |
String() string |
JSON string (implements fmt.Stringer) |
Because Go methods cannot introduce new type parameters, operations that change the element type are top-level functions.
// Map — transform each item
doubled := collections.Map(c, func(n int) int { return n * 2 })
// FlatMap — transform then flatten
tokens := collections.FlatMap(sentences, func(s string) []string {
return strings.Fields(s)
})
// Pluck — extract a field
names := collections.Pluck(users, func(u User) string { return u.Name })
// Reduce — fold to a single value
sum := collections.Reduce(c, 0, func(acc, n int) int { return acc + n })// GroupBy — map[K]*Collection[T]
byDept := collections.GroupBy(employees, func(e Employee) string { return e.Dept })
// KeyBy — map[K]T (last item wins on duplicate key)
byID := collections.KeyBy(users, func(u User) int { return u.ID })
// MapToMap — build a map with separate key and value functions
m := collections.MapToMap(users,
func(u User) int { return u.ID },
func(u User) string { return u.Name },
)key := func(n int) any { return n }
diff := collections.Diff(a, b, key) // items in a but not b
inter := collections.Intersect(a, b, key) // items in both a and bpass, fail := collections.Partition(scores, func(s int) bool { return s >= 50 })nested := collections.Collect([][]int{{1, 2}, {3, 4}})
flat := collections.Flatten(nested) // [1, 2, 3, 4]Works with any Number type (int, int64, float64, …).
sum := collections.Sum(prices)
min, _ := collections.Min(prices)
max, _ := collections.Max(prices)
avg, _ := collections.Avg(prices)
// By a derived key
youngest, _ := collections.MinBy(users, func(u User) int { return u.Age })
oldest, _ := collections.MaxBy(users, func(u User) int { return u.Age })// Sort by numeric key
sorted := collections.SortBy(users, func(u User) int { return u.Age })
desc := collections.SortByDesc(users, func(u User) int { return u.Age })
// Sort by string key
alpha := collections.SortByString(users, func(u User) string { return u.Name })pairs := collections.Zip(names, scores) // *Collection[[2]any]
pairs.Each(func(p [2]any) {
fmt.Println(p[0], p[1])
})package main
import (
"fmt"
collections "github.com/hymns/go-collections"
)
type Product struct {
Name string
Category string
Price float64
InStock bool
}
func main() {
products := collections.New(
Product{"Laptop", "Electronics", 2999.00, true},
Product{"Headphones", "Electronics", 299.00, false},
Product{"Desk", "Furniture", 899.00, true},
Product{"Chair", "Furniture", 459.00, true},
Product{"Monitor", "Electronics", 1299.00, true},
)
// Available electronics under RM 2000, sorted by price
result := collections.SortBy(
products.
Filter(func(p Product) bool { return p.InStock && p.Category == "Electronics" }).
Reject(func(p Product) bool { return p.Price >= 2000 }),
func(p Product) float64 { return p.Price },
)
result.Each(func(p Product) {
fmt.Printf("%s — RM %.2f\n", p.Name, p.Price)
})
// Headphones — RM 299.00 (out of stock, filtered out)
// Monitor — RM 1299.00
// Total value of in-stock items
total := collections.Sum(
collections.Pluck(
products.Filter(func(p Product) bool { return p.InStock }),
func(p Product) float64 { return p.Price },
),
)
fmt.Printf("Total stock value: RM %.2f\n", total)
// Group by category
byCategory := collections.GroupBy(products, func(p Product) string { return p.Category })
for cat, group := range byCategory {
fmt.Printf("%s: %d products\n", cat, group.Count())
}
}Run with go test ./tests/... -bench=. -benchmem.
Benchmarked on Apple M3 Pro, Go 1.24, comparing against equivalent plain Go slice code.
| Operation | Size | Collection | Plain Go | Overhead |
|---|---|---|---|---|
| Filter | 100 | 249 ns | 256 ns | ~0% |
| 10k | 17.2 µs | 17.2 µs | ~0% | |
| 100k | 212 µs | 212 µs | ~0% | |
| Map | 100 | 107 ns | 113 ns | ~0% |
| 10k | 6.7 µs | 6.8 µs | ~0% | |
| 100k | 61 µs | 65 µs | ~0% | |
| Reduce | 100 | 34 ns | 30 ns | ~0% |
| 10k | 2.8 µs | 2.8 µs | ~0% | |
| 100k | 28 µs | 29 µs | ~0% | |
| Sort | 100 | 3.7 µs | 3.3 µs | ~13% |
| 10k | 421 µs | 377 µs | ~12% | |
| 100k | 4.4 ms | 4.0 ms | ~9% | |
| GroupBy | 100 | 7.6 µs | 5.5 µs | ~38% |
| 10k | 328 µs | 172 µs | ~91% | |
| 100k | 2.8 ms | 1.9 ms | ~48% | |
| Chain (Filter→Map→Sort) | 100 | 2.2 µs | 1.7 µs | ~32% |
| 10k | 235 µs | 200 µs | ~17% | |
| 100k | 2.6 ms | 2.2 ms | ~21% |
Contains, Reduce, and Sum produce 0 allocations/op at all sizes.
- Filter / Map — overhead is effectively zero; the abstraction is free.
- Sort — ~10% overhead from the defensive copy that keeps the original collection immutable.
- GroupBy — highest overhead because each group is wrapped in its own
Collection. Use plainmapif this is a hot path. - Chain — intermediate allocations add ~20% overhead vs a single-pass plain Go loop. Acceptable for most use cases.
go-collections is fully immutable — every operation returns a new collection and never modifies the receiver or the original slice passed in. This is a deliberate design choice that sets it apart from samber/lo and goforj/collection for two specific operations.
Note on versions: The
lo.Reversemutation behaviour described here applies tosamber/loup to at least v1.x. Always check the changelog of the exact version you are pinning.
Performance tradeoff: Immutability means every operation allocates a new backing array. For the vast majority of use-cases this cost is negligible, but if
ReverseorChunksits in a hot path, benchmark before committing to any library.
| Library | Behaviour |
|---|---|
go-collections |
Returns a new collection. Original is untouched. ✅ |
samber/lo |
Mutates the caller's slice in-place. ❌ |
goforj/collection |
Mutates the backing slice in-place, returns the same pointer. ❌ |
// lo — silent mutation
original := []int{1, 2, 3, 4, 5}
rev := lo.Reverse(original)
fmt.Println(original) // [5 4 3 2 1] — caller's slice is gone
fmt.Println(rev) // [5 4 3 2 1] — same pointer as original
// goforj — same pointer returned; calling twice undoes the reversal
c := collection.New([]int{1, 2, 3, 4, 5})
rev := c.Reverse()
fmt.Println(c.Items()) // [5 4 3 2 1] — original mutated!
fmt.Println(rev.Items()) // [5 4 3 2 1] — same pointer as c
// go-collections — always safe
c := collections.New(1, 2, 3, 4, 5)
rev := c.Reverse()
fmt.Println(c.All()) // [1 2 3 4 5] — untouched
fmt.Println(rev.All()) // [5 4 3 2 1]| Library | Behaviour |
|---|---|
go-collections |
Each chunk is an independent copy. ✅ |
samber/lo |
Each chunk is an independent copy. ✅ |
goforj/collection |
Chunks are sub-slices sharing the backing array — mutating a chunk mutates the original. ❌ |
// goforj — silent data corruption
c := collection.New([]int{1, 2, 3, 4, 5})
chunks := c.Chunk(2)
chunks[0][0] = 99
fmt.Println(c.Items()) // [99 2 3 4 5] — original corrupted
// go-collections — chunks are fully independent
c := collections.New(1, 2, 3, 4, 5)
chunks := c.Chunk(2)
chunks[0][0] = 99
fmt.Println(c.All()) // [1 2 3 4 5] — original intactTo get the same immutability guarantees, lo needs a manual copy before each call and goforj needs .Clone(). Without these steps, both produce the broken results shown above.
// go-collections — works as-is
c := collections.New(1, 2, 3, 4, 5)
rev := c.Reverse()
fmt.Println(c.All()) // [1 2 3 4 5]
fmt.Println(rev.All()) // [5 4 3 2 1]
// lo — copy before each call
original := []int{1, 2, 3, 4, 5}
cp := append([]int(nil), original...)
rev := lo.Reverse(cp)
fmt.Println(original) // [1 2 3 4 5]
fmt.Println(rev) // [5 4 3 2 1]
// goforj — Clone() before each call
c := collection.New([]int{1, 2, 3, 4, 5})
rev := c.Clone().Reverse()
fmt.Println(c.Items()) // [1 2 3 4 5]
fmt.Println(rev.Items()) // [5 4 3 2 1]Chaining two Reverse calls is where the difference between libraries becomes most visible.
// go-collections — each call returns a new collection; intermediate is always valid
c := collections.New(1, 2, 3, 4, 5)
rev := c.Reverse()
revrev := rev.Reverse()
fmt.Println(c.All()) // [1 2 3 4 5] — original untouched
fmt.Println(rev.All()) // [5 4 3 2 1] — intermediate preserved ✓
fmt.Println(revrev.All()) // [1 2 3 4 5]
// lo — rev is silently overwritten when passed back in
cp := append([]int(nil), []int{1, 2, 3, 4, 5}...)
rev := lo.Reverse(cp) // cp → [5 4 3 2 1], rev == cp (same slice)
revrev := lo.Reverse(rev) // rev/cp → [1 2 3 4 5]
fmt.Println(rev) // [1 2 3 4 5] — intermediate was silently overwritten
// lo — safe with a copy before every call
cp1 := append([]int(nil), []int{1, 2, 3, 4, 5}...)
rev := lo.Reverse(cp1)
cp2 := append([]int(nil), rev...)
revrev := lo.Reverse(cp2)
fmt.Println(rev) // [5 4 3 2 1] ✓
fmt.Println(revrev) // [1 2 3 4 5] ✓
// goforj — rev/revrev/c are all the same pointer
c := collection.New([]int{1, 2, 3, 4, 5})
rev := c.Reverse() // mutates c, returns c
revrev := rev.Reverse() // mutates c again, returns c
fmt.Println(rev.Items()) // [1 2 3 4 5] — rev was never [5 4 3 2 1]
// goforj — safe with Clone() before every call
c := collection.New([]int{1, 2, 3, 4, 5})
rev := c.Clone().Reverse()
revrev := rev.Clone().Reverse()
fmt.Println(rev.Items()) // [5 4 3 2 1] ✓
fmt.Println(revrev.Items()) // [1 2 3 4 5] ✓// go-collections — always safe
c := collections.New(1, 2, 3, 4, 5)
chunks := c.Chunk(2)
chunks[0][0] = 99
fmt.Println(c.All()) // [1 2 3 4 5]
// lo — also safe; lo copies each chunk internally
original := []int{1, 2, 3, 4, 5}
chunks := lo.Chunk(original, 2)
chunks[0][0] = 99
fmt.Println(original) // [1 2 3 4 5]
// goforj — must copy each sub-slice manually
c := collection.New([]int{1, 2, 3, 4, 5})
raw := c.Chunk(2)
safe := make([][]int, len(raw))
for i, chunk := range raw {
cp := make([]int, len(chunk))
copy(cp, chunk)
safe[i] = cp
}
safe[0][0] = 99
fmt.Println(c.Items()) // [1 2 3 4 5]The speed difference is the direct cost of safety:
goforj.Reversedoes N/2 in-place swaps (0 allocations). We allocate N elements and copy.goforj.Chunkcreates slice headers pointing into the original array (0 copies). We copy each chunk.
There is no algorithmic trick that closes this gap while preserving immutability. The benchmark penalty is the price paid for correctness.
Distributed under MIT License, please see license file within the code for more details.