cosmix/loom

loom-golang

Go language expertise for idiomatic, production-quality code.

First seen May 20, 2026

Installation

$ npx skills add cosmix/loom --skill loom-golang

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from cosmix/loom · top by installs.

npx skills add cosmix/loom

Browse all from cosmix/loom

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Repository health

Stars 53
License LICENSE
Default branch main
Open issues 0
Status Active

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 25,519 B
  • docs SUMMARY.md 80 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 5 installs

SKILL.md

Go Language Expertise

Overview

Idiomatic, production-grade Go: concurrency, error handling, interfaces, testing, and version-gated behavior. The Foundations below get code working; the Expert Practices section is the higher bar — each item states the mechanism so you can apply it beyond the case shown.

Foundations

Error Handling

// Sentinel errors for identity checks; custom types for structured context.
var ErrNotFound = errors.New("resource not found")

type ValidationError struct{ Field, Message string }

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation on %s: %s", e.Field, e.Message)
}

func fetchUser(id string) (*User, error) {
    u, err := db.GetUser(id)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, fmt.Errorf("user %s: %w", id, ErrNotFound) // wrap w/ %w
        }
        return nil, fmt.Errorf("fetching user %s: %w", id, err)
    }
    return u, nil
}

// errors.Is matches by identity down the %w chain; errors.As extracts a type.
var ve *ValidationError
if errors.As(err, &ve) { /* ve.Field, ve.Message */ }

%w vs %v is an API decision — see Expert Practices.

Concurrency

// Worker pool. Go 1.25+: wg.Go does Add(1)+launch+Done atomically (see Expert).
func workerPool(jobs <-chan Job, results chan<- Result, n int) {
    var wg sync.WaitGroup
    for range n {
        wg.Go(func() { // pre-1.25: wg.Add(1); go func(){ defer wg.Done(); ... }()
            for job := range jobs {
                results <- process(job)
            }
        })
    }
    wg.Wait()
    close(results) // sender closes, exactly once; never the receiver
}

// Context timeout — always defer cancel() even when the deadline fires.
func fetch(ctx context.Context, url string) ([]byte, error) {
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()
    req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    return io.ReadAll(resp.Body) // drain AND close — see Expert (conn reuse)
}

// select with nil-channel disable: a nil channel blocks forever, so setting a
// drained channel to nil removes it from the select without a sentinel flag.
select {
case v, ok := <-ch:
    if !ok { ch = nil; continue }
    use(v)
case <-ctx.Done():
    return ctx.Err()
}

// RWMutex-guarded state. Pointer receiver is mandatory (copying a Mutex breaks it).
type SafeCounter struct {
    mu    sync.RWMutex
    count map[string]int
}
func (c *SafeCounter) Inc(k string) { c.mu.Lock(); defer c.mu.Unlock(); c.count[k]++ }
func (c *SafeCounter) Get(k string) int { c.mu.RLock(); defer c.mu.RUnlock(); return c.count[k] }

Bounded concurrency with errgroup (preferred over hand-rolled fan-out): first non-nil error cancels the group's context and is returned by Wait; g.SetLimit(n) caps concurrent goroutines.

g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8)
results := make([]Result, len(urls))
for i, url := range urls { // Go 1.22+: no `i, url := i, url` needed
    g.Go(func() error {
        r, err := fetchURL(ctx, url)
        results[i] = r // distinct index per goroutine => no lock needed
        return err
    })
}
if err := g.Wait(); err != nil { return nil, err }

Mutex vs channel: use a Mutex to guard simple shared state (counters, caches, maps); use a channel to transfer ownership of data or coordinate goroutine lifecycles/pipelines. Don't reach for a channel where a mutex is plainly simpler.

Interfaces & Embedding

// Small interfaces; accept interfaces, return concrete types (see Expert).
type UserGetter interface {
    GetUser(ctx context.Context, id string) (*User, error)
}

// Embedding for composition (promotes Base's fields/methods onto User).
type Base struct{ ID string; CreatedAt, UpdatedAt time.Time }
type User struct {
    Base
    Email, Name string
}

Functional Options

Variadic Option func(*T) closures over a zero-value-defaulted struct — the idiomatic way to give a constructor optional, backward-compatible parameters.

type Server struct{ host string; port int; timeout time.Duration }
type Option func(*Server)

func WithPort(p int) Option { return func(s *Server) { s.port = p } }

func NewServer(opts ...Option) *Server {
    s := &Server{host: "localhost", port: 8080, timeout: 30 * time.Second}
    for _, opt := range opts {
        opt(s)
    }
    return s
}
// NewServer(WithPort(9000))

HTTP Servers

srv := &http.Server{
    Addr: addr, Handler: mux,
    ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second,
}
// ListenAndServe returns ErrServerClosed on graceful Shutdown — treat as non-error.
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { ... }

// Go 1.22+ method+wildcard routing in the stdlib mux — no framework needed for basics.
mux.HandleFunc("GET /api/users/{id}", h.GetUser)   // id := r.PathValue("id")
mux.HandleFunc("POST /api/users", h.CreateUser)

// Recovery middleware — a panic in a handler otherwise kills the whole server.
func recovery(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                slog.Error("panic", slog.Any("err", err), slog.String("path", r.URL.Path))
                http.Error(w, "internal server error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

Frameworks (gin, echo, fiber) add routing/binding sugar over this; the timeout, graceful-shutdown, and recovery concerns are identical.

Database Access (sqlx)

type User struct {
    ID    string `db:"id"`
    Email string `db:"email"`
}

func (r *Repo) GetByID(ctx context.Context, id string) (*User, error) {
    var u User
    err := r.db.GetContext(ctx, &u, `SELECT id, email FROM users WHERE id=$1`, id)
    if errors.Is(err, sql.ErrNoRows) {
        return nil, ErrNotFound
    }
    return &u, err
}

// Transaction helper: rollback on error (report both errors), commit on success.
func (r *Repo) WithTx(ctx context.Context, fn func(*sqlx.Tx) error) error {
    tx, err := r.db.BeginTxx(ctx, nil)
    if err != nil {
        return fmt.Errorf("begin tx: %w", err)
    }
    if err := fn(tx); err != nil {
        if rb := tx.Rollback(); rb != nil {
            return fmt.Errorf("rollback: %v (original: %w)", rb, err)
        }
        return err
    }
    return tx.Commit()
}

Always use the ...Context variants (GetContext, SelectContext, NamedExecContext) so queries honor cancellation.

Structured Logging (slog)

logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
logger.Info("request", slog.String("method", r.Method), slog.Duration("latency", time.Since(start)))
logger = logger.With(slog.String("request_id", rid)) // derive a scoped child logger

Log errors with slog.Any("err", err), never slog.String(..., err.Error()) — see Expert. Hot paths: slog.LogAttrs avoids allocation.

Testing

func TestAdd(t *testing.T) {
    tests := []struct {
        name    string
        a, b    int
        want    int
    }{
        {"positive", 2, 3, 5},
        {"mixed", -1, 5, 4},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel() // subtests run concurrently; Go 1.22+ needs no `tt := tt`
            if got := Add(tt.a, tt.b); got != tt.want {
                t.Errorf("Add(%d,%d)=%d want %d", tt.a, tt.b, got, tt.want)
            }
        })
    }
}

Run go test -race ./... in CI — the race detector (~10x slowdown) is the primary tool for the memory-model gotchas below, but only reports races actually exercised at runtime, so races need test coverage to surface. In go < 1.22 modules the tt := tt capture workaround is required; go fix ./... removes it after a 1.22 bump.

Benchmarks

// Go 1.24+: for b.Loop() — setup runs once per -count (not b.N times), and the
// runtime keeps params/results alive so the compiler can't elide the body.
func BenchmarkSort(b *testing.B) {
    data := generateData(1000) // runs once
    for b.Loop() {
        Sort(data)
    }
}

Expert Practices: Idioms, Anti-Patterns & Gotchas

Design

Define interfaces in the consumer; return concrete types from the producer. Go interfaces are satisfied implicitly, so a producer never needs to declare the interface its types satisfy. Per Go Code Review Comments: interfaces "belong in the package that uses values of the interface type, not the package that implements those values," and "do not define interfaces before they are used." A broad producer-side interface (created for mocking) couples every consumer to that shape instead of the minimal method set it uses. Idiomatic: the producer returns a concrete type (often T, frequently an unexported struct behind an exported func New() myType) so new methods can be added without breaking callers; each consumer declares the small interface it needs. Returning an interface is a contract, not style: it forces the producer's whole method set onto every consumer and blocks additive change — a new method on the concrete type is unusable without widening the interface (a breaking change).

// consumer/service.go — declares exactly what it uses
type UserGetter interface {
    GetUser(ctx context.Context, id string) (*User, error)
}
func NewService(g UserGetter) *Service { /* ... */ }

// producer/repo.go — returns a concrete type, idiomatic even when unexported
func NewUserRepository(db *sqlx.DB) *userRepository { /* ... */ }

Prefer synchronous functions; let callers add concurrency. Hiding goroutine creation forces concurrency on every caller — they lose control over lifetime, can't add timeouts, can't call synchronously, and testing is harder. The asymmetry is the point: making a sync function async is one line at the call site (go func(){ errs <- Process(ctx, item) }()); un-async-ing an async API requires rewriting every caller.

Design types so the zero value is usable. Every Go variable is zero-initialized; exploit it so a type needs no constructor and resists init-order bugs. sync.Mutex (zero = unlocked), bytes.Buffer, sync.WaitGroup all work at zero value. Corollaries: name bool fields so false is the safe default (prefer disabled over enabled); add a constructor only when init is genuinely non-trivial. Anti-patterns: requiring an Init() before use, or panicking on a zero value.

%w makes the wrapped error part of your public API; use %v at boundaries. fmt.Errorf("...: %w", err) lets callers errors.Is/errors.As the wrapped value, making it part of your contract. Wrap an internal sentinel like sql.ErrNoRows with %w and callers can depend on it — swapping your DB driver becomes a breaking change. Within an application, or when the sentinel is genuinely meant to be inspectable, use %w; at package/system boundaries (RPC, storage, external services) use %v to flatten to a string, or convert to your own exported sentinel first. Caveat: %v destroys chain identity — a later errors.Is against the original returns false with no compile error.

Use generics for type-identical code; use an interface when you only call methods. Reach for type parameters when you'd otherwise write the same code differing only by concrete type — containers and functions over slices/maps/channels of any element type. But (Go team's "When To Use Generics") "if all you need to do with a value is call a method on it, use an interface type, not a type parameter." func ReadSome[T io.Reader](r T) is strictly worse than func ReadSome(r io.Reader) — same speed, harder to read, no benefit.

Expose iteration with range-over-func (Go 1.23), not a full slice or a ForEach callback. Return iter.Seq[V] / iter.Seq2[K,V]; callers use ordinary for range. Beats returning []T (allocates the whole collection even on early break) and ad-hoc callbacks (non-standard, not composable). The stdlib adopted it (slices.All/Values, maps.Keys/Values, composing with slices.Collect/Sorted). Contract: the iterator must stop and return as soon as yield returns false.

Concurrency Gotchas

Goroutine lifetimes must be deterministic; never silently start a background goroutine in a library. Goroutines are not garbage collected — one blocked on a channel that never receives leaks for the life of the process. Code Review Comments: "make it clear when — or whether — goroutines exit." Treat lifetime as a contract: exit on ctx.Done() / a closed channel, or bound it to a function scope. The decision to start a background goroutine belongs to the application layer (main), not a library constructor.

// BAD: a library constructor that silently leaks
func NewCache(size int) *Cache {
    c := &Cache{}
    go c.evictionLoop() // never stops; leaks when the cache is abandoned
    return c
}

The memory model gives no happens-before on goroutine exit or unsynchronized flags. Sequential consistency holds only for data-race-free programs. Starting a goroutine is synchronized before its first statement (writes before go f() are visible inside f), but goroutine exit carries no happens-before guarantee, and a flag read/written without a channel, mutex, or atomic has none. So for !done {} may loop forever (the value can sit in a register), and a write just before a goroutine exits is not guaranteed visible elsewhere. The WaitGroup/channel you observe completion through is the synchronizer — not termination itself. Use sync.Once, sync/atomic, a channel, or a mutex — and go test -race to catch violations.

WaitGroup.Add must run before go. Calling wg.Add(1) inside the goroutine races with wg.Wait()Wait can return before the counter increments, stopping too early; the race detector does not reliably catch it. Increment before go. staticcheck flags this as SA2000. Go 1.25's wg.Go(func(){...}) does Add(1) + launch + deferred Done atomically — prefer it for new code.

Never copy a sync type after first use. Every sync primitive (Mutex, RWMutex, WaitGroup, Once, Cond, Map, Pool) holds internal state a copy silently invalidates. Most common trigger: a value receiver on a method of a struct embedding one — each call locks a copy, so the lock protects nothing. Always use pointer receivers on such types. go vet's copylock analyzer catches most cases (3-clause for coverage improved in Go 1.24).

func (c Cache) Set(k string, v int) { // BAD: value receiver copies the mutex
    c.mu.Lock() // locks a copy; the real cache stays unprotected
    defer c.mu.Unlock()
    c.data[k] = v
}

Context

context keys must be an unexported package-local type, never a built-in. A string or other built-in key lets any package using the same literal read or shadow your value (the context docs forbid built-in key types). An unexported named type makes the type system guarantee cross-package uniqueness — two packages each declaring type ctxKey struct{} produce distinct, non-equal key types. A zero-size struct{} key allocates nothing.

type ctxKey struct{} // unexported; unique to this package
func WithUserID(ctx context.Context, id string) context.Context {
    return context.WithValue(ctx, ctxKey{}, id)
}

Never store context.Context in a struct — pass it as the first argument. A context encodes the lifetime and cancellation scope of one logical operation; storing it makes it ambiguous which operations it governs, denies per-call deadlines, and invites leaks (Go team's "Contexts and structs"). The one accepted exception is retrofitting an existing API (as net/http.Request did), and even then duplicate methods (CallContext vs Call) are preferred. Corollary: don't drop the request context and start a fresh context.Background() inside helpers — thread ctx through every boundary.

Language Gotchas

The typed-nil interface trap: return the error interface, not a concrete error pointer. An interface value is nil only when both its type and value slots are unset. Assigning a typed nil pointer (var p MyError = nil) to an error return makes the interface hold (T=MyError, V=nil), which is non-nil — every if err != nil at the call site then fires even on success. Declare the return type as the error interface and return a bare untyped nil on success; never the concrete pointer. Applies to any interface. staticcheck flags the always-non-nil comparison as SA4023.

func returnsError() error {
    if bad() {
        return ErrBad // concrete value ONLY on the error path
    }
    return nil // bare untyped nil
}

defer evaluates arguments immediately; a named return lets a deferred closure mutate the result. defer f(x) evaluates x when the defer runs, not when f executes — so defer fmt.Println(i) captures i's current value. A deferred closure with no arguments captures by reference and sees later mutations. Combined with a named return, this is the idiomatic way to augment an error after return runs:

func doOp(id string) (err error) { // named return
    defer func() {
        if err != nil {
            err = fmt.Errorf("doOp %s: %w", id, err) // mutates result after return
        }
    }()
    return riskyOp(id)
}

defer in a loop queues until the function returns — the failure is FD exhaustion, not just memory. Every defer is queued until the enclosing function returns, so files opened in a loop stay open simultaneously and exhaust the OS file-descriptor limit long before the loop ends. Fix: extract a per-iteration function (preferred) or an inline IIFE so each defer runs per iteration.

func processFile(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer f.Close() // runs at end of THIS function, once per iteration
    return process(f)
}
for _, path := range files {
    if err := processFile(path); err != nil {
        return err
    }
}

A subslice shares the parent's backing array and spare capacity — cap it or copy. Reslicing never copies; a subslice inherits capacity extending into the parent's tail, so appending while capacity remains writes silently into the parent (no panic — just corruption). Two fixes: (1) the three-index full slice s[low:high:max] caps capacity to max-low so the first append beyond high reallocates — use it when returning a slice a caller will append to; (2) when keeping a small excerpt of a large buffer, copy into a fresh, exactly-sized slice so the large backing array can be GC'd.

parent := []int{1, 2, 3, 4, 5}
child := parent[1:3]      // cap extends to end of parent
child = append(child, 99) // overwrites parent[3] silently -> [1 2 3 99 5]

func head(s []int) []int { return s[0:1:1] } // first append reallocates; can't reach parent

Log errors with slog.Any("err", err), not slog.String("error", err.Error()). slog's built-in handlers special-case error-typed Attr values — JSONHandler calls Error(), TextHandler uses fmt.Sprint — so pre-stringifying is unnecessary and lossy: it discards the concrete type a custom handler or LogValuer could inspect. ("err" is convention, not a documented standard — pick a key and be consistent.)

HTTP response bodies must be drained AND closed to reuse the connection. Per net/http: "If the Body is not both read to EOF and closed, the ... RoundTripper may not be able to re-use a persistent TCP connection." Close() after a partial read does not return a keep-alive connection to the pool; with the default DefaultMaxIdleConnsPerHost = 2, leaking connections under load causes new dials and timeouts. Always defer resp.Body.Close() and consume the body — io.ReadAll when you need it, io.Copy(io.Discard, resp.Body) when you don't.

Return errors, don't panic, for ordinary runtime failures. panic is for programmer bugs (invariant violations), not for a missing file or bad input. Recover only at process/goroutine boundaries (e.g. HTTP recovery middleware). A library that panics on ordinary failures forces every caller to defer-recover.

Version-Gated Behavior

Gated by the go directive in go.mod; older modules keep old behavior. The directive gates language and runtime semantics, not just the minimum toolchain — review release notes when bumping it.

Go 1.22 scopes for-loop variables per iteration — delete x := x. Before 1.22 a loop's variables were created once and mutated each iteration, so closures/goroutines/parallel subtests captured a shared variable; the fix was tt := tt. Go 1.22 creates fresh variables each iteration (all loop forms). In 1.22+ modules tt := tt is dead code go fix ./... removes. Hazard: bumping to 1.22 can make parallel subtests that passed only by reading the last iteration's value start failing — run GOEXPERIMENT=loopvar go test ./... first.

Go 1.23 made timer/ticker channels unbuffered — the drain-before-Reset idiom can now deadlock. Pre-1.23, timer channels had capacity 1, so a stale tick could buffer and the safe Reset idiom drained first. In 1.23 they are unbuffered and the runtime guarantees no stale value after Stop/Reset, so call Reset directly. An unconditional drain (<-t.C with nothing pending) now blocks forever; even if !t.Stop() { <-t.C } is no longer needed. Unstopped Timers/Tickers are now GC'd once unreferenced. (GODEBUG=asynctimerchan=1 reverts.)

Since Go 1.22 the global math/rand is ChaCha8Rand — but still use crypto/rand for secrets. Go 1.20 auto-seeds from OS entropy; 1.22 backs it with ChaCha8Rand, so accidental math/rand use is "no longer a security catastrophe" — but it is not a substitute for crypto/rand. Use crypto/rand for any secret (crypto/rand.Text() in 1.24+, or read rand.Reader); use math/rand/v2 (1.22+) for non-secret randomness. math/rand.Seed is deprecated and, if called, forces the weak Go 1 generator.

token := rand.Text()             // crypto/rand, Go 1.24+: secret, base32, >=128 bits
idx := mathrand.IntN(len(items)) // math/rand/v2: non-secret

Modernize as part of the upgrade workflow — run go fix after bumping the directive:

go fix -diff ./...   # preview
go fix ./...         # apply

It rewrites interface{}any, sort.Sliceslices.SortFunc, atomic free-functions→typed atomics (atomic.Int64/Bool/Pointer[T], which also fix 32-bit alignment footguns), the x := x removal, and context.WithCancel in tests→t.Context(). Also adopt: testing.T.Context()/B.Loop() (1.24), runtime.AddCleanup (1.24, preferred over SetFinalizer), and the slices/maps/cmp packages (1.21).

Naming

Package names: no stutter, no util/common/helper grab-bags. A package name is always visible at the call site, so a symbol repeating it reads redundantly: http.HTTPError, chubby.ChubbyFile. Pick names that form a natural phrase — http.Error, chubby.File, io.Reader. Second smell: util/common/helper/misc/base say nothing about contents, force import aliases, and accumulate unrelated code — split by domain instead.

Verification Checklists

Before completing any Go change

  • gofmt/goimports clean; go vet ./... passes (catches copylock, printf, nil-func)
  • go build ./... and go test ./... green
  • go test -race ./... on any code touching goroutines/shared state
  • golangci-lint/staticcheck clean (SA2000 Add-before-go, SA4023 typed-nil) if configured
  • Every returned error is %w-wrapped with context, or deliberately %v-flattened at a boundary
  • No ignored errors (_ =) except deliberate, commented cases

Concurrency review

  • Every goroutine has a deterministic exit (ctx.Done(), closed channel, or bounded scope) — no library-level background goroutines
  • sync types are never copied (pointer receivers; no value passing of structs embedding them)
  • Shared state read/written only under a mutex/atomic/channel — no unsynchronized flags
  • Channels closed by the sender exactly once; receivers never close
  • context.Context is a first parameter, threaded through, never stored in a struct

API/interface review

  • Interfaces declared in the consumer; constructors return concrete types
  • Functions are synchronous unless concurrency is the caller's explicit request
  • Zero value usable, or a constructor exists because init is non-trivial
  • error return type is the interface; success path returns bare nil (no typed-nil trap)