Skip to content

hymns/go-collections

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

go-collections

GitHub release (latest SemVer) Go Version Go Report Card GoDoc MIT License

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.

Installation

go get github.com/hymns/go-collections

Quick Start

import 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
})

Creating Collections

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"})

Methods (chainable on *Collection[T])

Querying

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

Transforming

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

Slicing

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

Mutating (returns new collection)

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

Serialising

Method Description
ToJSON() ([]byte, error) Marshal to JSON
String() string JSON string (implements fmt.Stringer)

Top-level Generic Functions

Because Go methods cannot introduce new type parameters, operations that change the element type are top-level functions.

Transforming

// 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 })

Grouping & Lookup

// 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 },
)

Set Operations

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 b

Partitioning

pass, fail := collections.Partition(scores, func(s int) bool { return s >= 50 })

Flattening

nested := collections.Collect([][]int{{1, 2}, {3, 4}})
flat   := collections.Flatten(nested) // [1, 2, 3, 4]

Numeric Aggregates

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 })

Sorting Helpers

// 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 })

Zipping

pairs := collections.Zip(names, scores) // *Collection[[2]any]
pairs.Each(func(p [2]any) {
    fmt.Println(p[0], p[1])
})

Full Example

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())
    }
}

Benchmarks

Run with go test ./tests/... -bench=. -benchmem.

Benchmarked on Apple M3 Pro, Go 1.24, comparing against equivalent plain Go slice code.

vs Plain Go

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%

Zero-allocation operations

Contains, Reduce, and Sum produce 0 allocations/op at all sizes.

Notes

  • 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 plain map if this is a hot path.
  • Chain — intermediate allocations add ~20% overhead vs a single-pass plain Go loop. Acceptable for most use cases.

Safety Guarantees

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.Reverse mutation behaviour described here applies to samber/lo up 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 Reverse or Chunk sits in a hot path, benchmark before committing to any library.


Reverse

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]

Chunk

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 intact

Achieving safe behaviour with lo and goforj

To 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.

Reverse

// 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]

Reverse twice

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] ✓

Chunk

// 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]

Why is go-collections slower on these two operations?

The speed difference is the direct cost of safety:

  • goforj.Reverse does N/2 in-place swaps (0 allocations). We allocate N elements and copy.
  • goforj.Chunk creates 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.


License

Distributed under MIT License, please see license file within the code for more details.

About

A fluent, type-safe collection library for Go, inspired by Laravel Collections

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages