Help us shape the future of Go! Take the 2026 Go Developer Survey

fp-go-loom

module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 26, 2026 License: BSD-2-Clause

README

fp-go-loom

Go Reference CI/CD License

Reusable fp-go v2 combinators — predicates, ord/eq instances, parse helpers, option pattern-matching, and IOEither/Either bridges. Woven on top of fp-go v2; importable by any Go project.

Table of Contents

Prerequisites

Install

go get github.com/dictyBase/fp-go-loom

Packages

Package Exports
array Compact, ParseWith
either/parse ParseInt
eithererr ConstErr
ioeitherutils ToEither
matchopt Case, Const, Default, Alt, First, Match
predicate/ord IntOrd, Float64Ord, IntEq, Float64Eq, StringEq, IntBetween, IntBetweenInclusive, MinStrLen, MaxStrLen, StrLenEq, NotEqualF64, NotEqualInt, NotEqualStr, StrEq
predicate/array IsNonEmpty, MinLen, MaxLen, LenEq
predicate/bytes HasPositiveLen, IsNonEmpty
predicate/strings LastIndexOf, HasSuffix, ContainsRuneClass, HasAtSign, StrLenBetween
predicate/fs IsDir, IsRegular, IsDirInfo, IsRegularInfo
strutils JoinStrings
pipelinecheck Check, Require, Config, Violation, Reporter, ModuleRoot, CheckNoIfErrInTryCatch, RequireNoIfErrInTryCatch, CheckNoHandRolledFileJoinInArrayMap, RequireNoHandRolledFileJoinInArrayMap, CheckNoNonRawTryCatchCallback, RequireNoNonRawTryCatchCallback, CheckSafePrintf, RequireSafePrintf, CheckRedundantFold, RequireNoRedundantFold, FunctionPkgPath, ArrayPkgPath, FilepathPkgPath, IOEitherPkgPath, IOPkgPath, EitherPkgPath, DefaultAllowDirective, DefaultAllowAppliedSeedDirective, DefaultAllowTryCatchIfErrDirective, DefaultAllowHandRolledFileJoinDirective, DefaultAllowNonRawTryCatchDirective, DefaultAllowUnsafePrintfDirective, DefaultAllowRedundantFoldDirective

Note: package names may differ from the import path's last segment (e.g. array → arrutils, predicate/ord → predord). Alias explicitly as shown in the examples below.

Usage

Each snippet shows the package import and a representative call; outputs are shown in comments. Full signatures live on pkg.go.dev.

Arrays
import (
    "strconv"

    O "github.com/IBM/fp-go/v2/option"
    arrutils "github.com/dictyBase/fp-go-loom/array"
)

// Compact: drop None, keep Some values (catMaybes)
opts := []O.Option[string]{
    O.Some("a"), O.None[string](), O.Some("b"),
}
arrutils.Compact[string](opts) // []string{"a", "b"}

// ParseWith: map []string to []A, silently discarding parse errors
parseInts := arrutils.ParseWith(strconv.Atoi)
parseInts([]string{"1", "bad", "42", "x", "7"}) // []int{1, 42, 7}
Strings
import "github.com/dictyBase/fp-go-loom/strutils"

strutils.JoinStrings("a", "b", "c") // "abc"
strutils.JoinStrings()             // ""
Either & error handling
import (
    E "github.com/IBM/fp-go/v2/either"

    eitherparse "github.com/dictyBase/fp-go-loom/either/parse"
    eithererr "github.com/dictyBase/fp-go-loom/eithererr"
)

// ParseInt: string -> Either[error, int]
eitherparse.ParseInt("42")  // Right[error, int](42)
eitherparse.ParseInt("abc") // Left[error, int]("failed to parse int: ...")
E.IsRight(eitherparse.ParseInt("42")) // true

// ConstErr: stable sentinel error factory. The same errors.New instance is
// returned on every call, so errors.Is stays stable across invocations.
fn := eithererr.ConstErr[string]("invalid")
fn("anything") // error("invalid")
fn("a") == fn("b") // true — identical instance
IOEither bridge
import (
    IOE "github.com/IBM/fp-go/v2/ioeither"

    ioeutils "github.com/dictyBase/fp-go-loom/ioeitherutils"
)

// ToEither: force a lazy IOEither into an eager Either
ioe := IOE.Right[error, int](42)
ioeutils.ToEither[error, int](ioe) // Right[error, int](42)
Option pattern matching
import (
    O "github.com/IBM/fp-go/v2/option"

    MO "github.com/dictyBase/fp-go-loom/matchopt"
)

// Build pattern-match arms over Option
armNeg := MO.Const(func(n int) bool { return n < 0 }, "neg")
armZero := MO.Const(func(n int) bool { return n == 0 }, "zero")
armPos := MO.Default(func(n int) string { return "pos" })

classify := func(n int) string {
    arms := []O.Option[string]{armNeg(n), armZero(n), armPos(n)}
    return MO.First("unknown", arms)
}
classify(-5) // "neg"
classify(0)  // "zero"
classify(7)  // "pos"

// Alt: first Some wins, or None
MO.Alt([]O.Option[int]{
    O.None[int](), O.Some(1), O.Some(2),
}) // Some(1)

// Match: lazy dispatch over unapplied cases — later cases
// never run after a match (expensive guards stay cheap)
numType := MO.Match("unknown", []O.Kleisli[int, string]{
    armNeg, armZero, armPos,
})
numType(0) // "zero"; armPos never invoked
Predicates

Ord/eq instances and derived predicates (predicate/ord):

import predord "github.com/dictyBase/fp-go-loom/predicate/ord"

predord.MinStrLen(3)("hello")            // true — len(s) >= 3
predord.MaxStrLen(5)("hello")            // true — len(s) <= 5
predord.StrLenEq(3)("hey")               // true — len(s) == 3
predord.IntBetween(1, 10)(5)             // true — 1 <= x < 10 (exclusive)
predord.IntBetween(1, 10)(10)            // false
predord.IntBetweenInclusive(1, 10)(10)  // true — inclusive upper
predord.NotEqualInt(5)(6)                // true — x != v
predord.StrEq("go")("go")                // true — x == s

// Base instances to pass into fp-go combinators:
//   predord.IntOrd, predord.Float64Ord,
//   predord.IntEq, predord.Float64Eq, predord.StringEq

Slice-length predicates (predicate/array):

import predarrays "github.com/dictyBase/fp-go-loom/predicate/array"

predarrays.IsNonEmpty[int]()([]int{1})                  // true — len > 0
predarrays.IsNonEmpty[int]()([]int{})                    // false
predarrays.MinLen[string](3)([]string{"a", "b", "c"})    // true — len >= 3
predarrays.MaxLen[string](3)([]string{"a", "b"})         // true — len <= 3
predarrays.LenEq[int](2)([]int{1, 2})                    // true — len == 2

Byte predicates (predicate/bytes):

import predbytes "github.com/dictyBase/fp-go-loom/predicate/bytes"

predbytes.HasPositiveLen([]byte("data")) // true — len > 0
predbytes.HasPositiveLen([]byte{})       // false
predbytes.HasPositiveLen(nil)            // false
predbytes.IsNonEmpty([]byte("x"))        // true — alias for HasPositiveLen

String predicates from curried stdlib (predicate/strings):

import (
    "unicode"

    predstrings "github.com/dictyBase/fp-go-loom/predicate/strings"
)

predstrings.LastIndexOf("@")("foo@bar")                 // 3
predstrings.HasSuffix(".go")("hello.go")               // true
predstrings.ContainsRuneClass(unicode.IsUpper)("Hello") // true
predstrings.HasAtSign("user@example.com")               // true
predstrings.StrLenBetween(3, 5)("hello")                // true — inclusive

File predicates (predicate/fs): atomic predicates on fs.FileInfo plus generic factories that lift them onto a state type:

import predfs "github.com/dictyBase/fp-go-loom/predicate/fs"

// atomic + P.ContraMap + any extractor (field lambda, no lens)
var isDirInfo = F.Pipe1(predfs.IsDirInfo, P.ContraMap(func(s stat) fs.FileInfo {
	return s.info
}))

// factory + lens getter: no lambda, extractor comes from the lens
var isDir = predfs.IsDir(fileInfoLens.Get)
Pipeline and style checks

pipelinecheck flags fp-go CLI entrypoints that delegate the whole continuation to a single-use wrapper via F.Pipe1(seed, namedFn) instead of inlining the steps into one F.PipeN. Drop a small _test.go into any package whose run*/Action handlers should stay inline:

package cmd

import (
	"strings"
	"testing"

	pipelinecheck "github.com/dictyBase/fp-go-loom/pipelinecheck"
)

func TestPipelineContinuity(t *testing.T) {
	pipelinecheck.Require(t, pipelinecheck.Config{
		Roots: []string{"."},
		IsEntrypoint: func(name string) bool {
			return strings.HasPrefix(name, "run")
		},
	})
}

Opt a genuinely-reused wrapper out with a doc comment carrying a non-empty reason:

// fp-go:allow-pipe1-handoff reused by runA and runB
func runThing(ctx context.Context, c *cli3.Command) error {
	return F.Pipe1(seed(ctx, c), sharedHandler)
}

The function-package alias is resolved from each file's imports, so F, fn, or the default function all work. Analysis is syntax-only (see package doc for limitations).

Point-free branching rule

CheckBareIf flags bare if statements inside function literals passed as callbacks to fp-go Pipe*/Flow*, or to Map, Chain, and ChainFirst from Either, IO, IOEither, and Option. The rule resolves imports by canonical path, so custom aliases work. It ignores ordinary closures and TryCatchError callbacks. Replace branching with a pre-bound P.Fold(onFalse, onTrue) and apply the predicate at the pipeline call site.

Enable it in a project gate with RequirePointFreeBranching:

func TestPointFreeBranching(t *testing.T) {
	pipelinecheck.Require(t, pipelinecheck.Config{
		Roots:                     []string{"."},
		RequirePointFreeBranching: true,
	})
}

Or run the rule directly with RequireBareIf:

pipelinecheck.RequireBareIf(t, pipelinecheck.Config{
	Roots: []string{"."},
})

Opt out only for a documented exception: // fp-go:allow-bare-if <reason>. An empty reason does not suppress violations.

Fixed-root file mapping rule

RequireNoHandRolledFileJoinInArrayMap flags filepath.Join or filepath.FromSlash path construction inside a function literal passed as an fp-go array.Map mapper. The rule resolves imports by canonical path, so custom aliases work, while ordinary joins outside the mapper remain clean. Use one flipped FILE.Join mapper instead:

root := F.Pipe1(st.Path, F.Flip(FILE.Join))
IOE.Map[error](A.Map(root))

The gate does not require exact variable names or Pipe arity. Opt out only with // fp-go:allow-hand-rolled-file-join <reason>.

TryCatch raw-effect rules

RequireNoIfErrInTryCatch flags if err != nil / if nil != err inside an IOE.TryCatchError callback literal. TryCatchError should hold the raw SDK effect; projection and wrapping belong in IOE.MapLeft / IOE.Map. The rule applies to all functions, not just entrypoints. Resolve the IOEither import alias automatically (IOE, ioeither, or a custom alias).

RequireNoNonRawTryCatchCallback complements it by flagging clear fmt.Errorf wrapping, .Set(...) lens-setter calls, and selector-based success projection inside the callback. It does not flag arbitrary helper or SDK calls, and it does not require later MapLeft or Map stages. Opt out with // fp-go:allow-non-raw-trycatch <reason>.

func TestNoNonRawTryCatchCallback(t *testing.T) {
	pipelinecheck.RequireNoNonRawTryCatchCallback(t, pipelinecheck.Config{
		Roots: []string{"."},
	})
}

Opt out of the error-branch rule with // fp-go:allow-trycatch-iferr <reason>.

Safe-Printf rule

RequireSafePrintf flags IO.Printf calls whose literal or package-local constant format string has no real formatting verb. A verb-less format dumps the value via %!(EXTRA type=value) and can leak secrets such as API keys. The rule applies to all functions. The printf directive parser handles flags, [index], width/precision (digits, *, indexed stars), and the documented fmt verbs (%U, %F included; %w excluded — it is only meaningful to fmt.Errorf). Package-local string constants are resolved across files in the same package, including grouped, inherited, and concatenated ones.

func TestSafePrintf(t *testing.T) {
	pipelinecheck.RequireSafePrintf(t, pipelinecheck.Config{
		Roots: []string{"."},
	})
}

Opt out with // fp-go:allow-unsafe-printf <reason>.

Redundant-Fold rule

RequireNoRedundantFold flags E.Fold / E.Match calls whose Right (success) arm discards its argument by returning nil. That shape is "extract the Left error, return nil on Right" — the long form of E.ToError[A]. Two discard shapes are detected: an anonymous func whose body is a single return nil, and a function-package Constant* combinator wrapping nil or a T(nil) conversion. Reserve Fold for the case where both arms produce a real value from their input (durable rule 10).

func TestNoRedundantFold(t *testing.T) {
	pipelinecheck.RequireNoRedundantFold(
		t, pipelinecheck.Config{Roots: []string{"."}},
	)
}

Opt out with // fp-go:allow-redundant-fold <reason>.

Terminal-effect rules

RequireTerminalEffect protects the boundary where a pipeline stops being pure and performs an irreversible effect — a write, a send, a commit, a publish. Such an effect must run exactly once per request, and two shapes silently break that:

  1. Duplicate terminal fold — an E.Fold / IOE.Fold / Match whose two arms both reach a terminal function. The effect is then invoked once per branch, so the call sites drift apart, and a late failure cannot be recovered without emitting a second time.
  2. Recovery after a terminal step — an IOE.OrElse positioned after a terminal step inside the same F.PipeN. A failure raised inside the effect sends the pipeline into the recovery arm, which performs the effect again.

The fix for both is the same: converge the success and failure branches on one description value, then execute once after the convergence.

// flagged: recovery wraps a step that already sent
F.Pipe2(
	IOE.Of[error](msg),
	IOE.Chain(encodeAndSend),
	IOE.OrElse(recoverSend),
)

// clean: recover first, send once at the end
F.Pipe3(
	IOE.Of[error](msg),
	IOE.ChainEitherK(encode),
	IOE.OrElse(encodeFailure),
	IOE.Chain(send),
)

Both rules are inert until the project names its terminal effects, so adopting the gate cannot break an existing build:

func TestTerminalEffect(t *testing.T) {
	pipelinecheck.RequireTerminalEffect(t, pipelinecheck.Config{
		Roots:             []string{"."},
		TerminalFunctions: []string{"writeResponse", "send"},
	})
}

Names are matched bare, so a method such as Send needs no receiver. Reachability follows up to two package-local hops, which covers the common case of a thin wrapper (respondJSON → writeResponse) but not arbitrary call chains — the analysis stays syntax-only.

Opt out with // fp-go:allow-duplicate-terminal <reason> or // fp-go:allow-recovery-after-terminal <reason>.

Pipelines

The fp-go-concepts examples chain multiple combinators with F.Pipe to build real-world flows. These four pipelines show loom functions composed across packages. Every snippet was verified to compile and its asserted output to hold before transcription.

Either validation chain

Monadic E.Chain threads a value through stages; each E.FromPredicate fails fast with a stable sentinel from eithererr.ConstErr, so errors.Is keeps identity across calls.

import (
    E "github.com/IBM/fp-go/v2/either"
    F "github.com/IBM/fp-go/v2/function"
    Str "github.com/IBM/fp-go/v2/string"

    eithererr "github.com/dictyBase/fp-go-loom/eithererr"
    predord "github.com/dictyBase/fp-go-loom/predicate/ord"
)

func ValidateUsername(username string) E.Either[error, string] {
    return F.Pipe3(
        username,
        E.FromPredicate(
            Str.IsNonEmpty,
            eithererr.ConstErr[string]("username is required"),
        ),
        E.Chain(E.FromPredicate(
            predord.MinStrLen(3),
            eithererr.ConstErr[string](
                "username must be at least 3 characters",
            ),
        )),
        E.Chain(E.FromPredicate(
            predord.MaxStrLen(20),
            eithererr.ConstErr[string](
                "username must be at most 20 characters",
            ),
        )),
    )
}

ValidateUsername("alice") // Right[error, string]("alice")
ValidateUsername("")      // Left — "username is required"
ValidateUsername("ab")    // Left — "username must be at least 3 characters"
Parse and range-validate

eitherparse.ParseInt returns Either; E.Chain composes it with a range check built from predord.IntBetween. Parse failure and range failure surface as distinct lefts.

import (
    E "github.com/IBM/fp-go/v2/either"
    F "github.com/IBM/fp-go/v2/function"

    eitherparse "github.com/dictyBase/fp-go-loom/either/parse"
    eithererr "github.com/dictyBase/fp-go-loom/eithererr"
    predord "github.com/dictyBase/fp-go-loom/predicate/ord"
)

func ParsePort(portStr string) E.Either[error, int] {
    return F.Pipe1(
        eitherparse.ParseInt(portStr),
        E.Chain(E.FromPredicate(
            predord.IntBetween(1, 65536),
            eithererr.ConstErr[int](
                "port must be between 1 and 65535",
            ),
        )),
    )
}

ParsePort("8080") // Right[error, int](8080)
ParsePort("abc")  // Left — "failed to parse int: ..."
ParsePort("70000") // Left — "port must be between 1 and 65535"
CSV parse and filter

A.FilterMap keeps rows whose split passes predarrays.LenEq; each kept row is mapped into a struct via O.Map.

import (
    "strings"

    A "github.com/IBM/fp-go/v2/array"
    F "github.com/IBM/fp-go/v2/function"
    O "github.com/IBM/fp-go/v2/option"

    predarrays "github.com/dictyBase/fp-go-loom/predicate/array"
)

type Customer struct{ ID, Name string }

func ParseCustomers(lines []string) []Customer {
    return F.Pipe1(
        lines,
        A.FilterMap(func(line string) O.Option[Customer] {
            parts := strings.Split(line, ",")
            return F.Pipe2(
                parts,
                O.FromPredicate(predarrays.LenEq[string](2)),
                O.Map(func(p []string) Customer {
                    return Customer{ID: p[0], Name: p[1]}
                }),
            )
        }),
    )
}

ParseCustomers([]string{"1,Alice", "bad", "2,Bob", "3,Carol,Extra"})
// []Customer{{ID:"1", Name:"Alice"}, {ID:"2", Name:"Bob"}}
Password strength classification

predstrings.ContainsRuneClass and predord.MinStrLen/MaxStrLen compose into strength predicates via Pred.And; matchopt.Const + First classify. Strong implies medium, so arms run most-specific first.

import (
    "unicode"

    F "github.com/IBM/fp-go/v2/function"
    O "github.com/IBM/fp-go/v2/option"
    Pred "github.com/IBM/fp-go/v2/predicate"

    MO "github.com/dictyBase/fp-go-loom/matchopt"
    predord "github.com/dictyBase/fp-go-loom/predicate/ord"
    predstrings "github.com/dictyBase/fp-go-loom/predicate/strings"
)

func ClassifyPassword(pw string) string {
    hasUpper := predstrings.ContainsRuneClass(unicode.IsUpper)
    hasLower := predstrings.ContainsRuneClass(unicode.IsLower)
    hasDigit := predstrings.ContainsRuneClass(unicode.IsDigit)
    isLongEnough := predord.MinStrLen(8)
    isShort := predord.MaxStrLen(7)

    medium := F.Pipe2(
        isLongEnough, Pred.And(hasUpper), Pred.And(hasLower),
    )
    strong := F.Pipe1(medium, Pred.And(hasDigit))

    armStrong := MO.Const(strong, "strong")
    armMedium := MO.Const(medium, "medium")
    armWeak := MO.Const(isShort, "weak")

    return MO.First("unknown", []O.Option[string]{
        armStrong(pw), armMedium(pw), armWeak(pw),
    })
}

ClassifyPassword("123")         // "weak"
ClassifyPassword("Password")    // "medium"
ClassifyPassword("Password123") // "strong"

Project Structure

.
├── array/
│   ├── array.go               # Compact, ParseWith
│   └── array_test.go
├── either/
│   └── parse/
│       ├── parse.go           # ParseInt
│       └── parse_test.go
├── eithererr/
│   ├── errors.go              # ConstErr
│   └── errors_test.go
├── ioeitherutils/
│   └── ioeitherutils.go       # ToEither
├── matchopt/
│   └── matchopt.go            # Case, Const, Default, Alt, First, Match
├── predicate/
│   ├── ord/
│   │   ├── ord.go             # IntOrd, IntBetween, MinStrLen, ...
│   │   └── ord_test.go
│   ├── array/
│   │   ├── array.go           # IsNonEmpty, MinLen, MaxLen, LenEq
│   │   └── array_test.go
│   ├── bytes/
│   │   ├── bytes.go           # HasPositiveLen, IsNonEmpty
│   │   └── bytes_test.go
│   └── strings/
│       ├── strings.go         # LastIndexOf, HasSuffix, HasAtSign, ...
│       └── strings_test.go
├── strutils/
│   ├── strutils.go            # JoinStrings
│   └── strutils_test.go
└── pipelinecheck/
    ├── pipelinecheck.go       # Check, Require, Config, Violation
    ├── bareif.go              # Point-free branching rule
    ├── trycatch.go            # NoIfErrInTryCatch rule
    ├── printf.go              # SafePrintf rule + const resolution
    ├── pipelinecheck_test.go  # handoff + applied-seed tests
    └── stylecheck_test.go     # trycatch + printf tests

Development

# Run tests
gotestsum --format pkgname-and-test-fails --format-hide-empty-pkg -- ./...

# Run tests with verbose output
gotestsum --format testdox -- ./...

# Lint
golangci-lint run ./...

# Format (gofumpt + goimports + golines)
golangci-lint fmt

License

BSD-2-Clause. See LICENSE.

Directories

Path Synopsis
Package arrutils provides generic array utility functions built with fp-go v2 combinators.
Package arrutils provides generic array utility functions built with fp-go v2 combinators.
either
parse
Package eitherparse provides functions to parse strings into various types, returning Either[error, T] to handle parsing errors gracefully.
Package eitherparse provides functions to parse strings into various types, returning Either[error, T] to handle parsing errors gracefully.
Package eithererr provides utilities for working with functions that return either a value or an error.
Package eithererr provides utilities for working with functions that return either a value or an error.
examples
pipelinecheck command
Package main is a runnable example of a pipelinecheck gate over a compliant fp-go CLI entrypoint.
Package main is a runnable example of a pipelinecheck gate over a compliant fp-go CLI entrypoint.
Package ioeitherutils provides utility functions for working with IOEither.
Package ioeitherutils provides utility functions for working with IOEither.
Package loomcheck enforces "import the loom combinator instead of hand-rolling it" through a syntax-only AST check.
Package loomcheck enforces "import the loom combinator instead of hand-rolling it" through a syntax-only AST check.
Package matchopt provides helpers for building Option-based pattern-match arms.
Package matchopt provides helpers for building Option-based pattern-match arms.
Package pipelinecheck enforces fp-go style rules via syntax-only AST checks.
Package pipelinecheck enforces fp-go style rules via syntax-only AST checks.
predicate
array
Package predarrays provides generic slice-length predicates built with fp-go v2 combinators.
Package predarrays provides generic slice-length predicates built with fp-go v2 combinators.
bytes
Package predbytes provides predicates for []byte.
Package predbytes provides predicates for []byte.
fs
Package fs provides predicates over io/fs.FileInfo, derived from the FileInfo interface methods.
Package fs provides predicates over io/fs.FileInfo, derived from the FileInfo interface methods.
ord
Package predord provides reusable fp-go ord/eq instances and derived predicates.
Package predord provides reusable fp-go ord/eq instances and derived predicates.
strings
Package predstrings provides predicates on strings, derived from curried stdlib string functions.
Package predstrings provides predicates on strings, derived from curried stdlib string functions.
Package strutils provides utility functions for string operations using fp-go functional patterns.
Package strutils provides utility functions for string operations using fp-go functional patterns.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL