fp-go-loom

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