A practical guide to functional programming principles, extracted from Minh Quang Tran’s The Art of Functional Programming (2024). Go-focused examples using fluentfp, with OCaml originals for FP-specific concepts.

This guide serves two purposes:

  1. FP theory — Tran’s principles of abstraction, composition, and immutability
  2. FP practice — Complete fluentfp API reference with examples (see Section 22)

Tran’s book distills functional programming to its essence: abstraction and composition. These aren’t just academic concepts—they’re why FP code tends to be shorter, more reusable, and easier to test. I’ve been applying these patterns with fluentfp in Go, and they’ve changed how I think about collection processing. This guide captures the ideas I reach for most often.


1. The Goal: Abstraction and Composition

Functional programming excels at two things: abstraction and composition.

Abstraction = capturing general computation patterns as reusable functions Composition = building complex programs from simpler building blocks

These aren’t new ideas. But FP takes them further than imperative programming by:

  • Treating functions as first-class values (pass them around like data)
  • Eliminating side effects (functions always return same output for same input)
  • Using immutable data (never modify, only create new)

“Functional programming excels at abstraction and composition.”

The result: code that’s easier to understand, test, and compose into larger systems.


2. Why FP Matters

The von Neumann Bottleneck

Imperative programming is conceptually tied to the von Neumann architecture:

  • Program = sequence of instructions
  • Main task = move data between CPU and memory
  • Primary concern = updating memory cells stepwise

This makes abstraction and composition harder:

// Imperative: sequence of memory updates
int sum = 0; i = 0;
while (i <= n) {
   i = i + 1;
   sum = sum + i * i;
}

The loop is a single unit. Can’t break it into smaller reusable components.

Functional Approach

(* Functional: composition of reusable parts *)
(fold (+) 0 . map square) [1..n]

Built from reusable components: map, fold, function composition (.).

Only the square function and + are specific to this program—everything else is general-purpose.

Declarative vs Imperative

Imperative Functional
How to compute step-by-step What the result should be
Mental execution required Structure reveals intent
Statements change state Expressions evaluate to values

The declarative trend extends beyond FP: Maven over Ant, React over jQuery, Terraform over scripts.


3. Everything is an Expression

In imperative languages, constructs split into two worlds:

  • Expressions: evaluate to values (1 + 2, "hello")
  • Statements: perform actions (if, while, assignment)

In FP, everything is an expression. There are no statements.

The Closure Property

Why does this matter? The closure property:

“An operation for combining data objects satisfies the closure property if the results of combining things with that operation can themselves be combined using the same operation.”

Think LEGO: connect two bricks, get a new brick that can connect to other bricks.

In FP:

  • Combine two expressions → get a new expression
  • That expression can combine with other expressions
  • Unlimited composability

If as Expression

Imperative if is a statement:

// Java: can't use if inside an expression
int x = (if (a > b) { return a; } else { return b; }); // ERROR

Functional if is an expression:

(* OCaml: if evaluates to a value *)
let x = if a > b then a else b

This enables composition:

(* Can use if-expression as operand *)
(if 1 > 2 then 0 else 42) + 10  (* Result: 52 *)

Go Parallel

Go lacks a ternary operator, but fluentfp provides one:

import "github.com/binaryphile/fluentfp/ternary"

// Fluent ternary expression
max := ternary.If[int](a > b).Then(a).Else(b)

// For expensive computations, use ThenCall/ElseCall to defer evaluation
result := ternary.If[string](cached).
    Then(cachedValue).
    ElseCall(expensiveComputation)

The Then/Else values are evaluated immediately (like function arguments). Use ThenCall/ElseCall when the value computation is expensive and should only run if selected.


4. Lambda Calculus: The Foundation

Lambda calculus is the theoretical foundation of all functional programming.

Three Building Blocks

Only three constructs exist:

Construct Example Meaning
Variable x, y Name for a value
Function abstraction λx. x Anonymous function taking x, returning x
Function application f x Apply function f to argument x

Despite this simplicity, lambda calculus is Turing-complete—as powerful as any programming language.

Function Abstraction

Mathematical function: f(x) = x * x Lambda calculus: λx. x * x

In OCaml:

(* Anonymous function *)
fun x -> x * x

(* Named function *)
let square = fun x -> x * x

(* Syntactic sugar *)
let square x = x * x

In Go:

// Anonymous function
func(x int) int { return x * x }

// Named function
square := func(x int) int { return x * x }

// Function declaration
func square(x int) int { return x * x }

Lambda Reduction

To evaluate: substitute argument into function body.

(λx. x * x) 3
→ 3 * 3
→ 9

Simple concept, profound implications.


5. First-Class Functions

Functions are first-class citizens when they can be:

  • Assigned to variables
  • Passed as arguments
  • Returned from other functions

Imperative Languages: Second-Class Functions

In traditional imperative languages, functions are special—they can’t be treated like data:

// Java (before lambdas): can't assign method to variable
double square(int x) { return x * x; }
// Can't do: var f = square;

FP: Functions Are Values

(* OCaml: function is just a value *)
let square = fun x -> x * x
let apply_twice f x = f (f x)
apply_twice square 2  (* Result: 16 *)

In Go:

// Go: functions are first-class
square := func(x int) int { return x * x }
applyTwice := func(f func(int) int, x int) int {
    return f(f(x))
}
applyTwice(square, 2)  // Result: 16

This enables higher-order functions—the hallmark of FP.


6. Currying and Partial Application

Multi-Argument Functions

Lambda calculus only allows single-argument functions. But we can represent multi-argument functions as chains of single-argument functions:

λx. λy. x + y

This is currying (named after Haskell Curry).

How It Works

Apply one argument at a time:

(λx. λy. x + y) 3 5
→ (λy. 3 + y) 5
→ 3 + 5
→ 8

Partial Application

Stop before applying all arguments:

(* OCaml *)
let add x y = x + y
let add3 = add 3       (* Partial application: fix x=3 *)
add3 5                 (* Result: 8 *)

In Go:

// Go: explicit closure for partial application
func add(x int) func(int) int {
    return func(y int) int { return x + y }
}
add3 := add(3)
add3(5)  // Result: 8

Why it matters: Create specialized functions from general ones without rewriting.


7. Higher-Order Functions

Functions that take other functions as arguments or return functions as results.

Climbing the Abstraction Hierarchy

From specific:

let rec sum_integers a b = if a > b then 0 else a + sum_integers (a+1) b
let rec sum_squares a b = if a > b then 0 else (a*a) + sum_squares (a+1) b

To general:

let rec accumulate f init a b =
  if a > b then init
  else f a (accumulate f init (a+1) b)

let sum_integers = accumulate (+) 0
let sum_squares = accumulate (fun x acc -> x*x + acc) 0

One function captures the pattern. Specific versions are just configurations.

Taking it too far: Don’t abstract prematurely. If you only have sumIntegers and sumSquares, two explicit functions might be clearer than accumulate. Abstract when you have three or more variations, or when the pattern is genuinely reusable.

Go Example

// Specific functions
func sumIntegers(a, b int) int {
    sum := 0
    for i := a; i <= b; i++ { sum += i }
    return sum
}

func sumSquares(a, b int) int {
    sum := 0
    for i := a; i <= b; i++ { sum += i * i }
    return sum
}

// General pattern
func accumulate(f func(int, int) int, init, a, b int) int {
    result := init
    for i := a; i <= b; i++ {
        result = f(i, result)
    }
    return result
}

// Specific as configurations
sumIntegers := func(a, b int) int {
    return accumulate(func(x, acc int) int { return x + acc }, 0, a, b)
}
sumSquares := func(a, b int) int {
    return accumulate(func(x, acc int) int { return x*x + acc }, 0, a, b)
}

8. Recursion: The FP Loop

FP has no for or while loops. Repeated computation uses recursion.

Why No Loops?

Loops require mutable state:

// Must mutate sum and i
int sum = 0;
for (int i = 0; i < n; i++) {
    sum += i;
}

Recursion uses immutable values:

let rec sum n =
  if n = 0 then 0
  else n + sum (n - 1)

Each recursive call creates new bindings. Nothing is mutated.

Tail Recursion

Problem: deep recursion blows the stack.

Solution: tail recursion—recursive call is the last operation.

(* Not tail-recursive: must compute n + result after recursive call *)
let rec sum n = if n = 0 then 0 else n + sum (n - 1)

(* Tail-recursive: recursive call is last operation *)
let rec sum_tail acc n = if n = 0 then acc else sum_tail (acc + n) (n - 1)
let sum n = sum_tail 0 n

Tail-recursive functions can be optimized to loops (constant stack space).

Go Pattern

// Iteration (Go's natural style)
func sum(n int) int {
    result := 0
    for i := 1; i <= n; i++ {
        result += i
    }
    return result
}

// Recursive style (for FP patterns)
func sumRec(n int) int {
    if n == 0 {
        return 0
    }
    return n + sumRec(n-1)
}

// Tail-recursive with accumulator
func sumTail(acc, n int) int {
    if n == 0 {
        return acc
    }
    return sumTail(acc+n, n-1)
}

Go doesn’t optimize tail calls, so iteration is usually preferred.


9. Compound Data Types

Tuples: Fixed-Size Heterogeneous Collections

(* OCaml: tuple of different types *)
let person = ("Alice", 30, true)  (* string * int * bool *)

(* Destructure with pattern matching *)
let (name, age, active) = person

Go parallel:

// Go: use struct for named tuples
type Person struct {
    Name   string
    Age    int
    Active bool
}

// Or multiple return values (unnamed tuple)
func getPerson() (string, int, bool) {
    return "Alice", 30, true
}
name, age, active := getPerson()

Lists: Variable-Size Homogeneous Collections

(* OCaml: list construction *)
let nums = [1; 2; 3]
let more = 0 :: nums  (* Prepend: [0; 1; 2; 3] *)

(* Pattern matching to destructure *)
let head :: tail = nums  (* head=1, tail=[2;3] *)

Key insight: Lists are immutable. 0 :: nums creates a new list.

Algebraic Data Types

Combine types with AND (product) and OR (sum):

(* Product type: AND *)
type point = { x: float; y: float }  (* x AND y *)

(* Sum type: OR *)
type shape =
  | Circle of float          (* radius *)
  | Rectangle of float * float  (* width * height *)

Go parallel:

// Product type (struct)
type Point struct {
    X, Y float64
}

// Sum type (interface + variants)
type Shape interface {
    Area() float64
}

type Circle struct {
    Radius float64
}

type Rectangle struct {
    Width, Height float64
}

Pattern Matching

Destructure and dispatch in one construct:

let area shape =
  match shape with
  | Circle r -> 3.14159 *. r *. r
  | Rectangle (w, h) -> w *. h

Go parallel:

func area(s Shape) float64 {
    switch v := s.(type) {
    case Circle:
        return math.Pi * v.Radius * v.Radius
    case Rectangle:
        return v.Width * v.Height
    default:
        return 0
    }
}

The Option Type

The most common sum type: Option (or Maybe)—a value that might not exist.

(* OCaml: option type *)
type 'a option = None | Some of 'a

(* Safe division *)
let safe_div x y =
  if y = 0 then None
  else Some (x / y)

(* Using pattern matching *)
match safe_div 10 2 with
| None -> "undefined"
| Some n -> string_of_int n

Go with fluentfp:

import "github.com/binaryphile/fluentfp/option"

// Create options
found := option.Of("hello")           // Ok option with value
missing := option.NotOk[string]()     // Not-ok option (explicit factory)
// Also valid: option.Basic[string]{} - zero value is not-ok

// From pointer (nil-safe conversion)
var ptr *string = nil
opt := option.FromOpt(ptr)            // Not-ok if nil

// From comparable (zero-value safe)
opt := option.IfProvided("")          // Not-ok if empty string

// Unwrap with defaults
value := opt.Or("default")            // Value or default
value := opt.OrEmpty()                // Value or zero value
value := opt.OrCall(expensiveFn)      // Value or lazy default

// Check and extract
if val, ok := opt.Get(); ok {
    // Use val
}

// Transform (map over option) - type-specific methods
opt.ToInt(func(s string) int { return len(s) })  // Option[int]

// Transform - generic Map function (for any return type)
type User struct { Name string }
userOpt := option.Map(opt, func(s string) User { return User{Name: s} })

// Filter
opt.KeepOkIf(func(s string) bool { return len(s) > 0 })

// Environment variables (returns Option[string])
port := option.Getenv("PORT").Or("8080")

Advanced option methods:

// Check status without extracting
if opt.IsOk() {
    // proceed knowing value exists
}

// Panic-based unwrap (use sparingly, for cases where not-ok is a bug)
value := opt.MustGet()  // panics if not-ok

// Filter with negation (complement of KeepOkIf)
nonEmpty := opt.ToNotOkIf(func(s string) bool { return s == "" })

// Side effect without extracting
opt.Call(func(s string) { log.Println("Found:", s) })

// Convert back to pointer (for APIs expecting *T)
ptr := opt.ToOpt()  // nil if not-ok, *value if ok

// Transform to same type
upper := opt.ToSame(strings.ToUpper)

The Option type makes “might not exist” explicit in the type system rather than using nil/null conventions.


10. Immutability

In FP, data is immutable. Once created, it never changes.

Why Immutability?

  1. No hidden state changes: Function behavior is predictable
  2. Thread-safe by default: No race conditions
  3. Easy to reason about: What you see is what you get
  4. Enables structural sharing: New versions share unchanged parts

Immutable Updates

Instead of mutating, create new values with changes:

(* OCaml: "update" creates new record *)
let p1 = { x = 1.0; y = 2.0 }
let p2 = { p1 with x = 3.0 }  (* New record: { x=3.0, y=2.0 } *)
(* p1 is unchanged *)

Go pattern:

// Immutable update pattern
func (p Point) WithX(x float64) Point {
    return Point{X: x, Y: p.Y}
}

p1 := Point{X: 1.0, Y: 2.0}
p2 := p1.WithX(3.0)  // p1 unchanged

List Operations Are Non-Destructive

let xs = [1; 2; 3]
let ys = 0 :: xs     (* ys = [0;1;2;3], xs unchanged *)
let zs = List.map (fun x -> x * 2) xs  (* zs = [2;4;6], xs unchanged *)

Taking it too far: Immutability has costs. In Go, each FP operation allocates a new slice. For hot paths with large data, profile before committing to functional style. Sometimes a mutable loop is the right choice.


11. Map: Transform Each Element

The map function applies a transformation to every element:

map f [a, b, c] = [f(a), f(b), f(c)]

Basic Usage

(* OCaml *)
List.map (fun x -> x * x) [1; 2; 3]
(* Result: [1; 4; 9] *)

Go:

// Go (with fluentfp)
import "github.com/binaryphile/fluentfp/slice"

squares := slice.From([]int{1, 2, 3}).ToInt(square)
// Result: [1, 4, 9]

// Or inline
slice.From([]int{1, 2, 3}).ToInt(func(x int) int { return x * x })

// Convert: same-type transformation (T → T)
doubled := slice.From([]int{1, 2, 3}).Convert(func(x int) int { return x * 2 })
// Result: [2, 4, 6]

Use Convert when the output type matches the input type. Use ToInt, ToString, etc. when converting to a different type.

Key Insight: Map Preserves Structure

Map transforms elements but preserves the container’s shape:

  • List of 3 elements → List of 3 elements
  • Tree structure → Same tree structure
  • Optional value → Optional value
map: (a → b) → Container[a] → Container[b]

Map as Lifting

Think of map as lifting a function to work on containers:

square: int → int
map square: []int → []int

Regular function becomes container-aware without knowing about containers.

Taking it too far: Don’t chain map operations when a single pass suffices. slice.From(xs).ToInt(f).ToInt(g) creates two intermediate slices. For performance-critical code, combine: slice.From(xs).ToInt(func(x int) int { return g(f(x)) }).


12. Filter: Select Elements

The filter function keeps only elements satisfying a predicate:

(* OCaml *)
List.filter (fun x -> x mod 2 = 0) [1; 2; 3; 4; 5]
(* Result: [2; 4] *)

Go:

import "github.com/binaryphile/fluentfp/slice"

isEven := func(x int) bool { return x%2 == 0 }
evens := slice.From([]int{1, 2, 3, 4, 5}).KeepIf(isEven)
// Result: [2, 4]

// RemoveIf: the complement (remove matching elements)
odds := slice.From([]int{1, 2, 3, 4, 5}).RemoveIf(isEven)
// Result: [1, 3, 5]

KeepIf and RemoveIf are complements. Use whichever reads more naturally for your predicate.

Composing Predicates

Build complex filters from simple ones:

let is_even x = x mod 2 = 0
let is_positive x = x > 0
let both p1 p2 x = p1 x && p2 x

List.filter (both is_even is_positive) [-2; -1; 0; 1; 2; 3; 4]
(* Result: [2; 4] *)

Go:

// Compose predicates with simple function
func both[T any](p1, p2 func(T) bool) func(T) bool {
    return func(x T) bool { return p1(x) && p2(x) }
}

isEven := func(x int) bool { return x%2 == 0 }
isPositive := func(x int) bool { return x > 0 }

slice.From(nums).KeepIf(both(isEven, isPositive))

Additional Slice Operations

// TakeFirst: get first N elements
top3 := slice.From(scores).TakeFirst(3)

// Each: apply side effect to each element (use sparingly)
slice.From(users).Each(func(u User) { log.Println(u.Name) })

// Len: get length (useful for method chaining)
count := slice.From(items).KeepIf(isValid).Len()

Each is for side effects and doesn’t return a value—use it at the end of pipelines for I/O operations.


13. Fold: Reduce to Single Value

The fold function combines all elements into one value:

fold f init [a, b, c] = f(a, f(b, f(c, init)))

Basic Usage

(* OCaml *)
List.fold_right (+) [1; 2; 3; 4] 0
(* Result: 10 *)

List.fold_right ( * ) [1; 2; 3; 4] 1
(* Result: 24 *)

Go:

import "github.com/binaryphile/fluentfp/slice"

sum := func(acc, x int) int { return acc + x }
total := slice.Fold([]int{1, 2, 3, 4}, 0, sum)
// Result: 10

Fold Is Universal

Many list operations can be expressed as fold:

(* length via fold *)
let length l = List.fold_right (fun _ acc -> acc + 1) l 0

(* map via fold *)
let map f l = List.fold_right (fun x acc -> f x :: acc) l []

(* filter via fold *)
let filter p l = List.fold_right (fun x acc -> if p x then x :: acc else acc) l []

(* any: true if any element satisfies predicate *)
let any p l = List.fold_right (fun x acc -> p x || acc) l false

(* all: true if all elements satisfy predicate *)
let all p l = List.fold_right (fun x acc -> p x && acc) l true

Fold is the “universal” list function. Map, filter, and more are special cases.

Taking it too far: Just because you can implement everything as fold doesn’t mean you should. slice.From(xs).KeepIf(pred) is clearer than a fold that reconstructs a filtered list. Use fold when you genuinely need to reduce to a different type.

fold_left vs fold_right

fold_right f [a,b,c] init = f(a, f(b, f(c, init)))  (* Right to left *)
fold_left f init [a,b,c] = f(f(f(init, a), b), c)   (* Left to right *)

fold_left is tail-recursive and more efficient for large lists.


14. Zip: Combine Two Lists

The zip function pairs elements from two lists:

(* OCaml *)
let rec zip l1 l2 = match (l1, l2) with
  | ([], _) -> []
  | (_, []) -> []
  | (h1::t1, h2::t2) -> (h1, h2) :: zip t1 t2

zip [1; 2; 3] ["a"; "b"; "c"]
(* Result: [(1, "a"); (2, "b"); (3, "c")] *)

zipWith: Combine with Function

let rec zipWith f l1 l2 = match (l1, l2) with
  | ([], _) -> []
  | (_, []) -> []
  | (h1::t1, h2::t2) -> f h1 h2 :: zipWith f t1 t2

zipWith (+) [1; 2; 3] [10; 20; 30]
(* Result: [11; 22; 33] *)

Go with fluentfp:

import "github.com/binaryphile/fluentfp/tuple/pair"

// Zip two slices into pairs
names := []string{"Alice", "Bob", "Carol"}
ages := []int{30, 25, 35}
pairs := pair.Zip(names, ages)
// Result: [{Alice 30}, {Bob 25}, {Carol 35}]

// ZipWith applies a function to corresponding elements
add := func(a, b int) int { return a + b }
sums := pair.ZipWith([]int{1, 2, 3}, []int{10, 20, 30}, add)
// Result: [11, 22, 33]

// Create a single pair
p := pair.Of("key", 42)  // Pair[string, int]

Note: fluentfp’s Zip panics if slices have different lengths. Check lengths first if unsure.


15. Dataflow Programming

Functions as Dataflow Components

Think of FP functions as components in a data pipeline:

Input → [filter] → [map] → [fold] → Output

Each function:

  • Accepts input
  • Produces output
  • Has no side effects

Composition Example

Sum of squares of even numbers:

(* OCaml: dataflow style with pipe operator *)
let sum_even_squares a b =
  enumerate_integers a b
  |> List.filter (fun x -> x mod 2 = 0)
  |> List.map (fun x -> x * x)
  |> List.fold_left (+) 0

sum_even_squares 1 10
(* Result: 220 *)

Go:

func sumEvenSquares(nums []int) int {
    isEven := func(x int) bool { return x%2 == 0 }
    square := func(x int) int { return x * x }
    sum := func(acc, x int) int { return acc + x }

    evens := slice.From(nums).KeepIf(isEven)
    squares := slice.From(evens).ToInt(square)
    return slice.Fold(squares, 0, sum)
}

Unix Philosophy Parallel

Eric Raymond’s Rule of Composition:

“Favor small, independent programs that do one thing and do it well… build programs whose inputs and outputs are text streams.”

cat file.txt | grep "error" | sort | uniq -c

Same principle: small, composable units with standard interfaces.


16. Lazy Evaluation and Streams

Call-by-Value vs Call-by-Name

Call-by-value (OCaml, Go, most languages): Evaluate arguments before applying function.

Call-by-name (Haskell): Substitute argument into function body unevaluated.

(λy. z) (infinite-loop)

Call-by-value: hangs forever (tries to evaluate infinite-loop first) Call-by-name: returns z immediately (never needs the argument)

Streams: Delayed Lists

Lists eagerly compute all elements. Streams compute elements on demand.

(* OCaml: stream type *)
type 'a stream = Nil | Cons of 'a * (unit -> 'a stream)

(* Infinite stream of integers from n *)
let rec from n = Cons (n, fun () -> from (n + 1))

(* Take first k elements *)
let rec take k s = match (k, s) with
  | (0, _) -> []
  | (_, Nil) -> []
  | (n, Cons (x, f)) -> x :: take (n-1) (f ())

take 5 (from 1)
(* Result: [1; 2; 3; 4; 5] *)

Go Channel Pattern

Go channels naturally support lazy evaluation:

// Infinite stream of integers
func integers(start int) <-chan int {
    ch := make(chan int)
    go func() {
        for i := start; ; i++ {
            ch <- i
        }
    }()
    return ch
}

// Take first n
func take[T any](n int, ch <-chan T) []T {
    result := make([]T, 0, n)
    for i := 0; i < n; i++ {
        result = append(result, <-ch)
    }
    return result
}

take(5, integers(1))  // [1, 2, 3, 4, 5]

17. Practical Application: Collections

Real-world collection processing using FP patterns:

E-Commerce Example

type Product struct {
    Name     string
    Category string
    Price    float64
    InStock  bool
}

// Predicates as methods enable fluent chaining
func (p Product) IsElectronics() bool { return p.Category == "electronics" }
func (p Product) IsInStock() bool     { return p.InStock }
func (p Product) GetPrice() float64   { return p.Price }

// Find average price of in-stock electronics
func avgElectronicsPrice(products []Product) float64 {
    isTarget := func(p Product) bool {
        return p.IsElectronics() && p.IsInStock()
    }

    prices := slice.From(products).KeepIf(isTarget).ToFloat64(Product.GetPrice)

    if len(prices) == 0 {
        return 0
    }

    sum := func(acc, p float64) float64 { return acc + p }
    total := slice.Fold(prices, 0.0, sum)
    return total / float64(len(prices))
}

Method References for Clarity

// fluentfp works elegantly with method references
type Developer struct {
    Name   string
    Status string
}

func (d Developer) IsIdle() bool { return d.Status == "idle" }
func (d Developer) GetName() string { return d.Name }

// Reads like English: "from developers, keep if idle, get names"
idleNames := slice.From(developers).KeepIf(Developer.IsIdle).ToString(Developer.GetName)

Unzip: Extract Multiple Fields Efficiently

When you need multiple fields from each element, use Unzip instead of multiple map passes:

type Order struct {
    ID     int
    Amount float64
    Status string
}

// BAD: Two passes over the slice
ids := slice.From(orders).ToInt(Order.GetID)
amounts := slice.From(orders).ToFloat64(Order.GetAmount)

// GOOD: Single pass extracts both
ids, amounts := slice.Unzip2(orders, Order.GetID, Order.GetAmount)

// For 3 fields
ids, amounts, statuses := slice.Unzip3(orders, Order.GetID, Order.GetAmount, Order.GetStatus)

// For 4 fields (e.g., full order extraction)
type FullOrder struct {
    ID        int
    Amount    float64
    Status    string
    Customer  string
}
func (o FullOrder) GetID() int         { return o.ID }
func (o FullOrder) GetAmount() float64 { return o.Amount }
func (o FullOrder) GetStatus() string  { return o.Status }
func (o FullOrder) GetCustomer() string { return o.Customer }

ids, amounts, statuses, customers := slice.Unzip4(
    fullOrders,
    FullOrder.GetID,
    FullOrder.GetAmount,
    FullOrder.GetStatus,
    FullOrder.GetCustomer,
)

Lower-Order Functions (lof)

The lof package wraps Go builtins for use with higher-order functions:

import "github.com/binaryphile/fluentfp/lof"

// lof.StringLen wraps len() for strings - usable as function value
lengths := slice.From(words).ToInt(lof.StringLen)

// lof.Println wraps fmt.Println - usable with Each
slice.From(messages).Each(lof.Println)

Why? Go’s len is a builtin, not a function value—you can’t pass it directly to ToInt. lof.StringLen is a regular function that wraps it.

When to Use fluentfp vs Raw Loops

Use fluentfp when… Use raw loops when…
Transforming entire collections Early exit on first match
Filtering with clear predicates Complex multi-step mutations
Chaining operations readably Performance-critical hot paths
Method references available Accumulating into maps/sets
You want testable predicates Index access needed mid-loop

Decision flowchart:

  1. Does the operation fit map/filter/fold? → Use fluentfp
  2. Need early exit (break)? → Use raw loop
  3. Accumulating into a map? → Use raw loop (Go maps aren’t functional)
  4. Performance-critical inner loop? → Profile first, then decide
  5. Readability improves with chaining? → Use fluentfp

Example: When raw loop wins

// BAD: fluentfp forces full iteration
found := slice.From(users).KeepIf(User.IsAdmin)
if len(found) > 0 {
    return found[0]  // Only needed first match
}

// GOOD: raw loop with early exit
for _, u := range users {
    if u.IsAdmin() {
        return u  // Stop immediately
    }
}

Example: When fluentfp wins

// GOOD: clear transformation pipeline
activeNames := slice.From(users).
    KeepIf(User.IsActive).
    ToString(User.GetName)

// WORSE: harder to read
var activeNames []string
for _, u := range users {
    if u.IsActive() {
        activeNames = append(activeNames, u.GetName())
    }
}

18. Practical Application: JSON

Algebraic data types naturally represent JSON:

(* OCaml: JSON as ADT *)
type json =
  | JsonNull
  | JsonBool of bool
  | JsonNumber of float
  | JsonString of string
  | JsonArray of json list
  | JsonObject of (string * json) list

Higher-Order Functions on JSON

(* Map over all values in JSON *)
let rec map_json f j = match j with
  | JsonNull -> JsonNull
  | JsonBool b -> JsonBool (f b)
  | JsonNumber n -> JsonNumber n
  | JsonString s -> JsonString s
  | JsonArray arr -> JsonArray (List.map (map_json f) arr)
  | JsonObject obj -> JsonObject (List.map (fun (k, v) -> (k, map_json f v)) obj)

Go parallel with any or reflection (less elegant):

// Go: JSON is naturally map[string]any
func mapJSON(j any, f func(any) any) any {
    switch v := j.(type) {
    case map[string]any:
        result := make(map[string]any)
        for k, val := range v {
            result[k] = mapJSON(val, f)
        }
        return result
    case []any:
        result := make([]any, len(v))
        for i, val := range v {
            result[i] = mapJSON(val, f)
        }
        return result
    default:
        return f(v)
    }
}

19. Case Study: Log Analysis Pipeline

A real-world example showing FP principles in action.

The Problem

Parse server logs to find the top 10 slowest API endpoints:

2024-01-15 10:23:45 GET /api/users 234ms 200
2024-01-15 10:23:46 POST /api/orders 1523ms 201
2024-01-15 10:23:47 GET /api/health 12ms 200

Imperative Approach

// Imperative: mutation, nested loops, hard to test
func topSlowEndpoints(lines []string, n int) []Endpoint {
    endpoints := make(map[string][]int)
    for _, line := range lines {
        parts := strings.Fields(line)
        if len(parts) < 5 { continue }
        path := parts[2]
        ms, err := strconv.Atoi(strings.TrimSuffix(parts[3], "ms"))
        if err != nil { continue }
        endpoints[path] = append(endpoints[path], ms)
    }

    var results []Endpoint
    for path, times := range endpoints {
        sum := 0
        for _, t := range times { sum += t }
        results = append(results, Endpoint{path, float64(sum)/float64(len(times))})
    }

    sort.Slice(results, func(i, j int) bool {
        return results[i].AvgMs > results[j].AvgMs
    })

    if len(results) > n { results = results[:n] }
    return results
}

Functional Approach

type LogEntry struct {
    Path   string
    TimeMs int
    Status int
}

func (e LogEntry) GetPath() string { return e.Path }
func (e LogEntry) GetTimeMs() int  { return e.TimeMs }

// Parse: string → LogEntry (pure function)
func parseLogEntry(line string) (LogEntry, bool) {
    parts := strings.Fields(line)
    if len(parts) < 5 {
        return LogEntry{}, false
    }
    ms, err := strconv.Atoi(strings.TrimSuffix(parts[3], "ms"))
    if err != nil {
        return LogEntry{}, false
    }
    return LogEntry{Path: parts[2], TimeMs: ms}, true
}

// Aggregate: []LogEntry → map[string][]int (pure function)
func groupByPath(entries []LogEntry) map[string][]int {
    result := make(map[string][]int)
    for _, e := range entries {
        result[e.Path] = append(result[e.Path], e.TimeMs)
    }
    return result
}

// Average: []int → float64 (pure function)
func average(times []int) float64 {
    sum := slice.Fold(times, 0, func(a, b int) int { return a + b })
    return float64(sum) / float64(len(times))
}

// Compose the pipeline
func topSlowEndpoints(lines []string, n int) []Endpoint {
    // 1. Parse valid entries
    entries := make([]LogEntry, 0)
    for _, line := range lines {
        if e, ok := parseLogEntry(line); ok {
            entries = append(entries, e)
        }
    }

    // 2. Group by path and compute averages
    byPath := groupByPath(entries)
    results := make([]Endpoint, 0, len(byPath))
    for path, times := range byPath {
        results = append(results, Endpoint{path, average(times)})
    }

    // 3. Sort and take top n
    sort.Slice(results, func(i, j int) bool {
        return results[i].AvgMs > results[j].AvgMs
    })

    if len(results) > n {
        return results[:n]
    }
    return results
}

Why FP Helps Here

Aspect Imperative Functional
Testing Hard—requires logs, checks mutation Easy—test parseLogEntry, average in isolation
Reuse None—logic tangled in one function average works anywhere
Debugging Step through nested loops Inspect intermediate results
Change Risky—one change affects everything Safe—swap components

The functional version isn’t shorter, but each piece is testable in isolation. If average is correct and parseLogEntry is correct, the composition is likely correct.


20. Summary: Core Principles

The Two Pillars

Pillar Meaning Enabled By
Abstraction Capture patterns as reusable functions Higher-order functions
Composition Build complex from simple Pure functions, immutability

Key Techniques

Technique Description
First-class functions Functions as values, pass and return them
Higher-order functions Functions that take or return functions
Currying Multi-arg function as chain of single-arg functions
Partial application Fix some arguments, get specialized function
Pattern matching Destructure and dispatch on shape
Immutability Never mutate, only create new
Recursion Loops via function calling itself

Core Computation Patterns

Function Purpose Type Signature
map Transform each element (a → b) → [a] → [b]
filter Keep elements matching predicate (a → bool) → [a] → [a]
fold Reduce to single value (a → b → b) → b → [a] → b
zip Pair elements from two lists [a] → [b] → [(a, b)]

Dataflow Mindset

Think of programs as pipelines:

Data → [filter] → [map] → [fold] → Result

Small, focused functions. Standard interfaces. Compose freely.


21. Quick Reference for CLAUDE.md

### Functional Programming: Tran's Principles

**Core insight:** FP excels at abstraction (reusable patterns) and composition (building complex from simple).

**First-class functions:**
- Assign functions to variables
- Pass functions as arguments
- Return functions from functions

**Key patterns:**
| Pattern | Purpose | Example |
|---------|---------|---------|
| map | Transform each element | `map square [1,2,3] = [1,4,9]` |
| filter | Keep matching elements | `filter even [1,2,3,4] = [2,4]` |
| fold | Reduce to single value | `fold (+) 0 [1,2,3,4] = 10` |

**Immutability:** Never mutate data. Create new values with changes.

**Composition:** Connect functions via shared interfaces:

data |> filter pred |> map transform |> fold combine init


**Everything is an expression:** No statements. If-then-else evaluates to a value.

**Algebraic data types:**
- Product types (AND): struct with fields
- Sum types (OR): interface with variants
- Pattern matching: destructure + dispatch

22. fluentfp Quick Reference

Complete API reference for fluentfp:

slice package

Function Signature Purpose
From From[T]([]T) Mapper[T] Create fluent slice
KeepIf .KeepIf(func(T) bool) Filter (keep matching)
RemoveIf .RemoveIf(func(T) bool) Filter (remove matching)
Convert .Convert(func(T) T) Map same type
ToInt .ToInt(func(T) int) Map to int
ToString .ToString(func(T) string) Map to string
ToFloat64 .ToFloat64(func(T) float64) Map to float64
ToBool .ToBool(func(T) bool) Map to bool
ToAny .ToAny(func(T) any) Map to any
Each .Each(func(T)) Side effect per element
TakeFirst .TakeFirst(n int) First n elements
Len .Len() int Slice length
Fold Fold[T,R]([]T, R, func(R,T)R) Reduce to single value
Unzip2 Unzip2[T,A,B]([]T, fa, fb) Extract 2 fields in one pass
Unzip3 Unzip3[T,A,B,C]([]T, fa, fb, fc) Extract 3 fields
Unzip4 Unzip4[T,A,B,C,D]([]T, fa, fb, fc, fd) Extract 4 fields

option package

Function Signature Purpose
Of Of[T](T) Basic[T] Create ok option
NotOk NotOk[T]() Basic[T] Create not-ok option
New New[T](T, bool) Basic[T] Create from value + ok
FromOpt FromOpt[T](*T) Basic[T] From pointer (nil-safe)
IfProvided IfProvided[T comparable](T) Not-ok if zero value
Getenv Getenv(string) String From environment variable
Map Map[T,R](Basic[T], func(T)R) Transform value
.Get .Get() (T, bool) Unwrap with ok
.IsOk .IsOk() bool Check if ok
.MustGet .MustGet() T Unwrap or panic
.Or .Or(T) T Value or default
.OrCall .OrCall(func() T) T Value or lazy default
.OrEmpty .OrEmpty() T Value or zero (for strings)
.OrZero .OrZero() T Value or zero (generic)
.OrFalse .OrFalse() T Value or zero (for bools)
.KeepOkIf .KeepOkIf(func(T) bool) Filter option
.ToNotOkIf .ToNotOkIf(func(T) bool) Filter with negation
.ToInt .ToInt(func(T) int) Transform to int option
.ToSame .ToSame(func(T) T) Transform same type
.Call .Call(func(T)) Side effect if ok
.ToOpt .ToOpt() *T Convert to pointer

ternary package

Function Signature Purpose
If If[R](bool) Ternary[R] Start ternary
.Then .Then(R) Ternary[R] Value if true
.ThenCall .ThenCall(func() R) Lazy value if true
.Else .Else(R) R Value if false, returns result
.ElseCall .ElseCall(func() R) R Lazy value if false

tuple/pair package

Function Signature Purpose
Of Of[A,B](A, B) X[A,B] Create pair
Zip Zip[A,B]([]A, []B) []X[A,B] Zip two slices
ZipWith ZipWith[A,B,R]([]A, []B, func(A,B)R) Zip with function

lof package

Function Signature Purpose
StringLen StringLen(string) int Wrap len for strings
Len Len[T]([]T) int Wrap len for slices
Println Println(string) Wrap fmt.Println

23. Connection to Khorikov (Testing)

FP and good testing are natural allies:

FP Principle Testing Benefit
Pure functions Same input → same output. No mocks needed.
Immutability No hidden state changes. Tests are deterministic.
Small functions Each testable in isolation
Composition Test components, trust the composition

Why Pure Functions Are Easy to Test

// Pure function: trivially testable
func average(times []int) float64 {
    sum := slice.Fold(times, 0, func(a, b int) int { return a + b })
    return float64(sum) / float64(len(times))
}

func TestAverage(t *testing.T) {
    tests := []struct {
        input []int
        want  float64
    }{
        {[]int{1, 2, 3}, 2.0},
        {[]int{10}, 10.0},
        {[]int{0, 0, 0}, 0.0},
    }
    for _, tc := range tests {
        got := average(tc.input)
        if got != tc.want {
            t.Errorf("average(%v) = %v, want %v", tc.input, got, tc.want)
        }
    }
}

No setup. No teardown. No mocks. The function’s entire behavior is determined by its inputs.

Khorikov’s Observable Behavior ↔ FP’s Pure Functions

Khorikov says: test observable behavior, not implementation details.

FP says: functions are defined by their input-output relationship.

These are the same idea. A pure function’s observable behavior is its input-output relationship. There’s nothing else to test.

If you need mocks, your function probably isn’t pure. Consider refactoring to separate pure logic from side effects.


24. Connection to Ousterhout (Design)

These two perspectives complement each other:

Tran (FP) Ousterhout (Design)
Higher-order functions Deep modules
map/filter/fold Simple interfaces hiding complexity
Composition Information hiding
Immutability Reducing dependencies
Pure functions Reducing obscurity

Key insight: FP’s higher-order functions are naturally “deep”—simple interface (function signature), rich functionality (reusable for many use cases).

map: (a → b) → [a] → [b]

Two arguments. Works for any transformation on any list. That’s a deep interface.

Composition reduces complexity:

  • Each function does one thing
  • Functions compose via standard interfaces (lists, functions)
  • No shared mutable state to track

If Ousterhout’s goal is “fighting complexity” and FP’s tools are “abstraction and composition,” they’re attacking the same problem from different angles.


Based on Tran, Minh Quang. “The Art of Functional Programming.” No Starch Press, 2024. ISBN 978-1718503410.