diff --git a/skills/fp-best-practices/SKILL.md b/skills/fp-best-practices/SKILL.md new file mode 100644 index 0000000..572b9c6 --- /dev/null +++ b/skills/fp-best-practices/SKILL.md @@ -0,0 +1,159 @@ +--- +name: fp-best-practices +description: Functional Programming guidance for writing and reviewing clean, composable code. Use when naming functions or types, designing pure functions, applying function composition or pipe, choosing between algebraic data types (Maybe, Option, Either, Result), controlling side effects or IO boundaries, using higher-order functions (map, filter, reduce, flatMap), applying currying or partial application, avoiding mutation and shared state, or reviewing FP code in JavaScript, TypeScript, Python, Go, Rust, Haskell, Elm, Clojure, F#, or Elixir. +license: MIT +metadata: + author: luckys + version: "1.0.0" +--- + +# FP Best Practices + +Use this skill for everyday functional programming decisions that shape composability, predictability, and long-term maintainability. + +Use it especially when the task benefits from: + +- clearer separation between pure logic and side effects +- smaller functions composed into pipelines +- types that make illegal states unrepresentable +- data transformations over mutations +- referential transparency and easier testing + +## Working Style + +1. Write functions for the next reader, not just for the runtime. +2. Keep side effects at the edges; keep the core pure. +3. Prefer small, composable functions over clever all-in-one implementations. +4. Use types and names to reveal intent. +5. Let the data structure do the work when it can. + +## Review Workflow + +1. Identify the transformation. + - What data goes in? What comes out? + - What domain concept is this function modeling? + +2. Check purity. + - Does this function have side effects (I/O, mutation, randomness)? + - Can the side effects be pushed to the boundary? + +3. Check composition. + - Is this function doing one thing? + - Can it be expressed as a composition of smaller functions? + - Is it reusable in other pipelines? + +4. Check types and edge cases. + - Are null/undefined/error cases handled explicitly via ADTs? + - Is the return type honest about what might fail? + +5. Apply the lightest useful improvement. + - extract function + - introduce pipe/compose + - replace null with Maybe/Option + - replace throws with Either/Result + - push side effect to boundary + - replace loop with map/filter/reduce + +## Additional Review Lenses + +### Purity and referential transparency + +- A pure function returns the same result for the same inputs, always. +- Pure functions are trivially testable, composable, and parallelizable. +- Impure operations (I/O, logging, random, time) belong at the edges of the system. + +### Composition over imperative sequencing + +- Prefer expressing pipelines over step-by-step instructions. +- Small functions combined with `pipe` or `compose` reveal the shape of the transformation. +- Point-free style removes noise when the data flow is already clear from the pipeline. + +### Algebraic data types over null and exceptions + +- Use `Maybe`/`Option` when a value may legitimately be absent. +- Use `Either`/`Result` when an operation may fail and the caller needs the error. +- Model domain variants as tagged union types (sum types) rather than optional fields. + +### Immutability as the default + +- Never mutate data in place — produce new values instead. +- Use `Object.freeze`, `readonly`, immutable records, or persistent data structures. +- Mutation is a side effect. Treat it as one. + +### Higher-order functions as vocabulary + +- `map` transforms each element of a structure without leaving the structure. +- `filter` selects elements by predicate. +- `reduce`/`fold` collapses a structure into a single value. +- `flatMap`/`chain` sequences computations that each return a wrapped value. + +## Day-to-Day Rules + +- Prefer pure functions. Extract impure operations to the boundary. +- Name functions by the transformation they perform, not the mechanism. +- Use `pipe` (left-to-right) or `compose` (right-to-left) to express data flow. +- Replace `null` with `Maybe`/`Option` when absence is meaningful. +- Replace `throw` with `Either`/`Result` when callers need to handle failures. +- Curry functions when partial application makes call sites cleaner. +- Prefer immutable data structures. Mutate only at system boundaries. +- Use `map`/`filter`/`reduce` over imperative loops by default. +- Keep functions small — one transformation, one level of abstraction. +- Avoid hidden state (closures that mutate captured variables are side effects). + +## Good Signals + +- Every function can be tested with just its inputs — no setup needed. +- Pipelines read like a description of the problem. +- Illegal states cannot be constructed from the types. +- Side effects are localized to clearly named edge functions. +- Changing one transformation does not break unrelated pipelines. + +## Warning Signs + +- Functions that mutate their inputs or external state. +- `null` or `undefined` passed as a meaningful sentinel value. +- Exceptions used for normal control flow. +- Long functions that mix data fetching, transformation, and output. +- Mutable shared state threaded through function parameters. +- `for` loops where `map`/`filter`/`reduce` would make intent clearer. +- Deeply nested callbacks or `.then()` chains that mix logic with I/O. + +## References + +- Read `references/core-principles.md` for the fundamental FP principles: purity, immutability, referential transparency, and composition. +- Read `references/naming-and-abstractions.md` when naming or abstraction quality is the main issue. +- Read `references/function-composition.md` for pipe, compose, currying, partial application, and point-free style. +- Read `references/algebraic-data-types.md` for Maybe/Option, Either/Result, tagged unions, and making illegal states unrepresentable. +- Read `references/managing-side-effects.md` for IO boundaries, Reader/Writer/State patterns, and effect systems. +- Read `references/higher-order-functions.md` for map, filter, reduce, flatMap, transducers, and functor/monad patterns. +- Read `references/language-examples.md` for an index of language-specific example files and a quick reference table. +- Read `references/javascript-examples.md` for examples in JavaScript (native, Ramda, fp-ts). +- Read `references/typescript-examples.md` for examples in TypeScript (fp-ts, Effect-TS, branded types). +- Read `references/python-examples.md` for examples in Python (functools, itertools, returns, pattern matching 3.10+). +- Read `references/go-examples.md` for examples in Go (first-class functions, generics, functional options, `(value, error)`). +- Read `references/rust-examples.md` for examples in Rust (Iterator trait, Option/Result, enums, newtype, `?` operator). +- Read `references/haskell-examples.md` for examples in Haskell (pure FP reference, typeclasses, IO monad). +- Read `references/elm-examples.md` for examples in Elm (frontend FP, The Elm Architecture, decoders). +- Read `references/clojure-examples.md` for examples in Clojure (threading macros, persistent data structures). +- Read `references/fsharp-examples.md` for examples in F# (computation expressions, discriminated unions, pipelines). +- Read `references/elixir-examples.md` for examples in Elixir (pipe operator, pattern matching, `with`, OTP). + +## Related Skills + +- Use `oop-best-practices` when the codebase mixes FP and OOP and object design is in question. +- Use `refactoring-best-practices` when introducing FP into an existing imperative codebase. +- Use `design-patterns-best-practices` when evaluating whether a pattern maps to a functional equivalent. +- Use `tdd-best-practices` for test-driving pure functions and effect boundaries. + +## Source Influences + +This skill is synthesized from ideas emphasized in: + +- *Mostly Adequate Guide to Functional Programming* — Brian Lonsdorf (Professor Frisby) +- *Professor Frisby's Mostly Adequate Guide to Functional Programming* +- *Haskell Programming from First Principles* — Allen & Moronuki +- *Structure and Interpretation of Computer Programs* — Abelson & Sussman +- *Functional Programming in Scala* — Chiusano & Bjarnason +- *Domain Modeling Made Functional* — Scott Wlaschin +- *Grokking Simplicity* — Eric Normand +- fp-ts, Effect-TS, and Ramda library documentation diff --git a/skills/fp-best-practices/references/algebraic-data-types.md b/skills/fp-best-practices/references/algebraic-data-types.md new file mode 100644 index 0000000..298f755 --- /dev/null +++ b/skills/fp-best-practices/references/algebraic-data-types.md @@ -0,0 +1,272 @@ +# Algebraic Data Types + +Use this reference when choosing between null/undefined and explicit types, modeling domain variants, or making illegal states unrepresentable. + +## What Are Algebraic Data Types? + +Algebraic Data Types (ADTs) are composite types formed by combining simpler types in two ways: + +- **Product types** — hold multiple values at once (records, tuples). Named "product" because the number of possible values is the product of each field's possibilities. +- **Sum types** — hold one of several variants (tagged unions, discriminated unions). Named "sum" because the total possibilities is the sum of each variant's possibilities. + +Together they let you model exactly what is possible in the domain — no more, no less. + +## Product Types (Records and Tuples) + +A product type holds values for all its fields simultaneously: + +```typescript +// TypeScript +type Point = { x: number; y: number }; +type UserProfile = { id: UserId; name: string; email: Email; role: Role }; +``` + +```haskell +-- Haskell +data Point = Point { x :: Double, y :: Double } +data UserProfile = UserProfile { userId :: UserId, name :: String, email :: Email, role :: Role } +``` + +Every combination of valid field values is a valid product type instance. + +## Sum Types (Tagged Unions / Discriminated Unions) + +A sum type holds exactly one of several possible variants. The tag (discriminant) identifies which variant it is: + +```typescript +// TypeScript — discriminated union +type Shape = + | { kind: "circle"; radius: number } + | { kind: "rectangle"; width: number; height: number } + | { kind: "triangle"; base: number; height: number }; + +function area(shape: Shape): number { + switch (shape.kind) { + case "circle": return Math.PI * shape.radius ** 2; + case "rectangle": return shape.width * shape.height; + case "triangle": return 0.5 * shape.base * shape.height; + } +} +``` + +```haskell +-- Haskell +data Shape + = Circle Double + | Rectangle Double Double + | Triangle Double Double + +area :: Shape -> Double +area (Circle r) = pi * r ^ 2 +area (Rectangle w h) = w * h +area (Triangle b h) = 0.5 * b * h +``` + +The compiler enforces exhaustiveness: if a new variant is added, every switch/match must be updated. + +## Maybe / Option — Explicit Absence + +`Maybe`/`Option` replaces `null`/`undefined` with a type that forces callers to handle absence explicitly. + +```typescript +// TypeScript with fp-ts +import { Option, some, none, match } from "fp-ts/Option"; + +function findUser(id: UserId): Option { + const user = db.users.get(id); + return user ? some(user) : none; +} + +// The caller cannot ignore the missing case +const displayName = pipe( + findUser(id), + match( + () => "Anonymous", // None case — user not found + (user) => user.name // Some case — user found + ) +); +``` + +```javascript +// JavaScript with union +const None = { _tag: "None" }; +const Some = value => ({ _tag: "Some", value }); + +const findUser = id => { + const user = db.get(id); + return user ? Some(user) : None; +}; +``` + +```elm +-- Elm +findUser : UserId -> Maybe User +findUser id = Dict.get id db + +-- Pattern matching is exhaustive +displayName : UserId -> String +displayName id = + case findUser id of + Nothing -> "Anonymous" + Just user -> user.name +``` + +### When to use Maybe/Option + +- A lookup that may not find a result (`findById`, `Dict.get`, `Array.find`) +- An optional configuration field +- A value that is intentionally absent (not an error) + +Do **not** use `Maybe` when absence is an error — use `Either`/`Result` instead. + +## Either / Result — Explicit Failure + +`Either`/`Result` replaces exceptions with a type that carries both the success value and the error, forcing callers to handle both. + +```typescript +// TypeScript with fp-ts +import { Either, left, right } from "fp-ts/Either"; + +type ValidationError = { field: string; message: string }; + +function parseAge(raw: string): Either { + const n = parseInt(raw, 10); + if (isNaN(n)) return left({ field: "age", message: "Must be a number" }); + if (n < 0 || n > 150) return left({ field: "age", message: "Out of range" }); + return right(n); +} + +// Chain operations — short-circuits on the first Left +const processAge = pipe( + parseAge(rawInput), + E.chain(validateMinimumAge), + E.map(ageToAgeGroup) +); +``` + +```haskell +-- Haskell — Either is built in +parseAge :: String -> Either String Int +parseAge s = case reads s of + [(n, "")] | n >= 0 && n <= 150 -> Right n + [(_, "")] -> Left "Out of range" + _ -> Left "Not a number" +``` + +```fsharp +// F# — Result type +let parseAge (raw: string) : Result = + match System.Int32.TryParse(raw) with + | true, n when n >= 0 && n <= 150 -> Ok n + | true, _ -> Error "Out of range" + | false, _ -> Error "Not a number" +``` + +### When to use Either/Result + +- Parsing or validation that can fail (parse user input, decode JSON) +- Operations that interact with external systems (database, HTTP, file system) +- Business rules that can be violated (`InsufficientFunds`, `ProductOutOfStock`) + +### Option vs Either/Result + +`Option` communicates only presence or absence. Use it when no reason is needed. If the caller must distinguish `NotFound`, `Forbidden`, or `Unavailable`, use `Either`/`Result` before that information is discarded. + +`Either` and `Result` have equivalent sum-type structure; naming and generic parameter order vary by ecosystem. Prefer the established language/library type over a home-grown implementation. Require exhaustive `match`/`fold`, use `flatMap`/`chain` for dependent computations, and avoid partial `get()`/`getError()` APIs that throw on the wrong branch. + +Expected typed failures can coexist with an exception/defect channel for bugs and unexpected infrastructure failures. An effect system models both channels explicitly, but the program must be executed by its runtime; awaiting an Effect value is not execution. + +Source lesson: [CodelyTV Domain Modeling Errors course](https://github.com/CodelyTV/domain_modeling-errors-course), generalized from its Optional, Either, Result, fp-ts, Scala, and Effect comparisons. + +## Making Illegal States Unrepresentable + +The goal is to design types so that the invalid combinations simply cannot be constructed. + +### Anti-pattern: optional flags create ambiguous states + +```typescript +// What does it mean when both are set? Or neither? +type Order = { + status: string; // "pending" | "shipped" | "delivered" | "cancelled" + shippedAt?: Date; + cancelledAt?: Date; + cancelReason?: string; +}; +``` + +### Better: each state carries only relevant data + +```typescript +type Order = + | { status: "pending"; createdAt: Date } + | { status: "shipped"; createdAt: Date; shippedAt: Date; trackingCode: string } + | { status: "delivered"; createdAt: Date; shippedAt: Date; deliveredAt: Date } + | { status: "cancelled"; createdAt: Date; cancelledAt: Date; reason: string }; +``` + +Now a `cancelled` order always has a `reason`. A `shipped` order always has a `trackingCode`. Invalid combinations do not type-check. + +## Branded / Nominal Types + +Prevent mixing up values of the same primitive type that represent different concepts: + +```typescript +// Without branding — compiler accepts UserId where OrderId is expected +type UserId = string; +type OrderId = string; + +// With branding — compiler rejects the wrong type +type UserId = string & { readonly _brand: "UserId" }; +type OrderId = string & { readonly _brand: "OrderId" }; + +const makeUserId = (id: string): UserId => id as UserId; +const makeOrderId = (id: string): OrderId => id as OrderId; + +function getUser(id: UserId): User { ... } +const orderId = makeOrderId("123"); +getUser(orderId); // Type error — OrderId is not UserId +``` + +## Recursive Types + +Sum types compose recursively to model tree-shaped data: + +```typescript +// Binary tree +type Tree = + | { tag: "Leaf" } + | { tag: "Node"; value: A; left: Tree; right: Tree }; + +// JSON value +type Json = + | null + | boolean + | number + | string + | Json[] + | { [key: string]: Json }; +``` + +```haskell +-- Haskell +data Tree a = Leaf | Node a (Tree a) (Tree a) + +data Json + = JNull + | JBool Bool + | JNumber Double + | JString String + | JArray [Json] + | JObject [(String, Json)] +``` + +## Decision Guide + +| Situation | Type to use | +|---|---| +| Value may or may not exist (not an error) | `Maybe` / `Option` | +| Operation may fail; caller needs the error | `Either` / `Result` | +| Multiple exclusive domain states | Sum type / discriminated union | +| Multiple fields that always coexist | Product type / record | +| Two primitives that must not be confused | Branded / nominal type | +| Hierarchical / recursive data | Recursive sum type | diff --git a/skills/fp-best-practices/references/clojure-examples.md b/skills/fp-best-practices/references/clojure-examples.md new file mode 100644 index 0000000..22bd5c3 --- /dev/null +++ b/skills/fp-best-practices/references/clojure-examples.md @@ -0,0 +1,297 @@ +# Clojure — Functional Programming Examples + +Concepts covered: pure functions, threading macros (pipe), currying/partial, nil handling, error handling, immutable data, higher-order functions, records/maps, side effect boundary. + +Clojure is a dynamic, LISP-family functional language on the JVM. All data structures are persistent and immutable by default. + +--- + +## Pure Functions and Referential Transparency + +```clojure +;; All Clojure functions are pure unless they call side-effecting functions +;; No mutation of arguments — always return new values + +(defn apply-discount [rate price] + (* price (- 1 rate))) + +;; apply-discount is referentially transparent: +;; (apply-discount 0.1 100) => 90.0 always + +(defn add-item [cart item] + (update cart :items conj item)) +;; conj returns a new collection — cart is unchanged +``` + +--- + +## Threading Macros (Pipe) + +```clojure +;; -> threads value as the FIRST argument (left-to-right) +(defn process-order [order] + (-> order + (update :items (partial filter :active)) + (assoc :total (calculate-total (:items order))) + (apply-tax 0.21) + format-for-response)) + +;; ->> threads value as the LAST argument — works naturally with sequence fns +(defn summarize-active-orders [orders] + (->> orders + (filter :active) + (map :total) + (reduce + 0))) + +;; as-> threads the value as a named binding for mixed argument positions +(as-> user $ + (assoc $ :email (str/lower-case (:email $))) + (update $ :name str/trim) + (validate-user $)) + +;; some-> short-circuits on nil (like Maybe chaining) +(defn get-city [user] + (some-> user :address :city)) +``` + +--- + +## Currying and Partial Application + +```clojure +;; Clojure is not auto-curried, but partial is built-in +(def apply-ten-percent (partial apply-discount 0.1)) +(apply-ten-percent 200) ; 180.0 + +;; Anonymous function shorthand +(def double (partial * 2)) +(map double [1 2 3]) ; (2 4 6) + +;; Closures as partial application +(defn make-tax-fn [rate] + (fn [price] (* price (+ 1 rate)))) + +(def add-vat (make-tax-fn 0.21)) +(add-vat 100) ; 121.0 + +;; comp — right-to-left composition +(def process-email + (comp str/lower-case str/trim)) + +(process-email " Alice@Example.com ") ; "alice@example.com" +``` + +--- + +## Nil Handling (Clojure's Maybe) + +```clojure +;; Clojure uses nil as the "nothing" value +;; nil punning: most core functions handle nil gracefully + +;; get returns nil if key not found — safe +(get {:name "Alice"} :email) ; nil +(get {:name "Alice"} :name) ; "Alice" + +;; some-> short-circuits the chain when nil is encountered +(defn get-city [user] + (some-> user + :address + :city + str/trim)) + +;; get-in — safe nested access (returns nil if any key missing) +(defn get-city [user] + (get-in user [:address :city])) + +;; fnil — wrap a function to replace nil with a default +(def safe-inc (fnil inc 0)) +(safe-inc nil) ; 1 +(safe-inc 5) ; 6 + +;; when-let — execute body only when binding is non-nil +(defn display-city [user] + (if-let [city (get-city user)] + (str "City: " city) + "City unknown")) +``` + +--- + +## Error Handling — Result-style with maps + +```clojure +;; Clojure convention: return {:ok true :value v} or {:ok false :error e} +(defn validate-email [email] + (if (str/includes? email "@") + {:ok true :value (str/lower-case email)} + {:ok false :error "Invalid email"})) + +(defn validate-age [age] + (if (and (integer? age) (<= 0 age 150)) + {:ok true :value age} + {:ok false :error "Age must be 0-150"})) + +(defn parse-user [{:keys [email age]}] + (let [email-r (validate-email email) + age-r (validate-age age)] + (cond + (not (:ok email-r)) email-r + (not (:ok age-r)) age-r + :else {:ok true :value {:email (:value email-r) :age (:value age-r)}}))) + +;; With clojure.spec for richer validation +(require '[clojure.spec.alpha :as s]) +(s/def ::email (s/and string? #(str/includes? % "@"))) +(s/def ::age (s/and integer? #(<= 0 % 150))) +(s/def ::user (s/keys :req-un [::email ::age])) + +(s/valid? ::user {:email "alice@example.com" :age 30}) ; true +(s/explain ::user {:email "notanemail" :age 30}) +``` + +--- + +## Immutable Data Structures + +```clojure +;; All Clojure data structures are persistent (immutable) +;; Operations return new values — the original is unchanged + +(def user {:id "1" :name "Alice" :email "alice@example.com"}) + +;; assoc — returns new map with key updated +(def updated (assoc user :email "new@example.com")) +user ; still {:id "1" :name "Alice" :email "alice@example.com"} +updated ; {:id "1" :name "Alice" :email "new@example.com"} + +;; assoc-in — nested update +(def user-with-address (assoc user :address {:city "Madrid" :zip "28001"})) +(assoc-in user-with-address [:address :city] "Barcelona") + +;; update — apply a function to an existing value +(update user :name str/upper-case) ; {:name "ALICE" ...} + +;; dissoc — remove a key +(dissoc user :email) + +;; merge — combine maps (rightmost wins) +(merge user {:email "new@example.com" :role :admin}) + +;; Persistent vectors +(def items [1 2 3]) +(conj items 4) ; [1 2 3 4] — items is still [1 2 3] +(pop items) ; [1 2] +``` + +--- + +## Higher-Order Functions + +```clojure +;; map — transform each element (returns lazy sequence) +(map #(* 2 %) [1 2 3]) ; (2 4 6) +(map :total orders) ; extracts :total from each map + +;; filter — select by predicate +(filter :active orders) ; orders where :active is truthy +(filter #(> (:total %) 100) orders) + +;; reduce — fold to a single value +(reduce + 0 [1 2 3 4 5]) ; 15 +(reduce (fn [m item] (assoc m (:id item) item)) {} items) ; index by id + +;; mapcat (= flatMap) — map then flatten one level +(mapcat :tags posts) ; all tags from all posts + +;; keep — like map but drops nils +(keep :city addresses) ; only non-nil cities + +;; group-by — partition by key function +(group-by :status orders) ; {"active" [...] "cancelled" [...]} + +;; sort-by — sort by key or function +(sort-by :total orders) +(sort-by :total > orders) ; descending + +;; take, drop, partition, take-while, drop-while +(->> (range 100) + (filter even?) + (take 5)) ; (0 2 4 6 8) + +;; Transducers — composable transforms, no intermediate collections +(def active-totals + (comp (filter :active) + (map :total))) + +(transduce active-totals + 0 orders) ; sum of active totals, one pass +``` + +--- + +## Sum Types — Records and Protocols + +```clojure +;; Clojure uses maps with a :type key as tagged unions +(defn make-pending [created-at] + {:type :pending :created-at created-at}) + +(defn make-shipped [created-at tracking-code] + {:type :shipped :created-at created-at :tracking-code tracking-code}) + +(defn make-cancelled [created-at reason] + {:type :cancelled :created-at created-at :reason reason}) + +(defn describe-status [status] + (case (:type status) + :pending "Awaiting shipment" + :shipped (str "Shipped: " (:tracking-code status)) + :cancelled (str "Cancelled: " (:reason status)) + (throw (ex-info "Unknown status" {:status status})))) + +;; Multimethods — dispatch on data, not class hierarchy +(defmulti area :shape) +(defmethod area :circle [{:keys [radius]}] (* Math/PI radius radius)) +(defmethod area :rectangle [{:keys [width height]}] (* width height)) + +(area {:shape :circle :radius 5}) ; 78.54... +(area {:shape :rectangle :width 4 :height 6}) ; 24 +``` + +--- + +## Side Effect Boundary + +```clojure +;; Pure core — all logic, no I/O +(defn process-registration [existing-emails input] + (let [email (-> input :email str/lower-case str/trim)] + (cond + (not (str/includes? email "@")) + {:ok false :error "Invalid email"} + + (contains? existing-emails email) + {:ok false :error "Email already registered"} + + :else + {:ok true :value {:id (str (java.util.UUID/randomUUID)) + :email email + :created-at (java.time.Instant/now)}}))) + +;; Impure shell — thin I/O layer +(defn register-user! [input] + (let [existing-emails (db/all-emails) ; I/O + result (process-registration existing-emails input)] ; pure + (when (:ok result) + (db/save-user! (:value result))) ; I/O + result)) + +;; Dependency injection via function arguments +(defn register-user! + ([input] (register-user! db/all-emails db/save-user! input)) + ([fetch-emails save-user! input] + (let [existing-emails (fetch-emails) + result (process-registration existing-emails input)] + (when (:ok result) (save-user! (:value result))) + result))) +``` diff --git a/skills/fp-best-practices/references/core-principles.md b/skills/fp-best-practices/references/core-principles.md new file mode 100644 index 0000000..ac21e53 --- /dev/null +++ b/skills/fp-best-practices/references/core-principles.md @@ -0,0 +1,147 @@ +# Core Principles + +Use these principles to guide everyday functional programming decisions. They improve predictability, testability, and composability. + +## Pure Functions + +A pure function: +- returns the same output for the same input, always +- produces no side effects (no I/O, no mutation, no randomness, no throwing) + +Pure functions are the unit of functional design. They can be tested without setup, composed freely, memoized safely, and parallelized without locks. + +```javascript +// Impure — depends on external state, mutates +let total = 0; +function addToTotal(amount) { + total += amount; // mutation of external state + return total; +} + +// Pure — same input always produces same output +function add(a, b) { + return a + b; +} +``` + +## Referential Transparency + +An expression is referentially transparent when it can be replaced by its value without changing the program's behavior. + +Pure functions are referentially transparent. Impure functions are not. + +```javascript +// Referentially transparent — can substitute the call with its result +const doubled = [1, 2, 3].map(x => x * 2); // always [2, 4, 6] + +// Not referentially transparent — result depends on when you call it +const now = () => new Date(); // different every call +``` + +Referential transparency makes code easier to reason about: you can evaluate any subexpression independently. + +## Immutability + +Never mutate data in place. Produce new values instead. + +```javascript +// Mutable — modifies the original +const items = [1, 2, 3]; +items.push(4); // side effect + +// Immutable — produces a new array +const moreItems = [...items, 4]; +``` + +Immutability eliminates a whole class of bugs: no hidden shared state, no surprising changes at a distance, no need for defensive copying. + +Immutability is the default in pure FP languages (Haskell, Elm, Clojure). In multi-paradigm languages, enforce it explicitly with `const`, `Object.freeze`, `readonly`, or immutable data libraries. + +## Separation of Pure and Impure + +Every program has side effects — reading files, writing to the database, printing output. The FP approach is not to eliminate effects but to isolate and control them. + +``` +┌───────────────────────────────────────┐ +│ Pure core │ +│ (transforms, validations, business │ +│ rules — no I/O, no mutation) │ +└───────────────┬───────────────────────┘ + │ data in / data out +┌───────────────▼───────────────────────┐ +│ Impure shell │ +│ (database, HTTP, file system, clock, │ +│ logging, randomness) │ +└───────────────────────────────────────┘ +``` + +The functional core / imperative shell pattern (Gary Bernhardt): +- The core holds all logic and produces new values. +- The shell reads from the world, calls the core, and writes the result back. + +```javascript +// Pure core — testable without any I/O +function applyDiscount(order, discountRate) { + return { ...order, total: order.total * (1 - discountRate) }; +} + +// Impure shell — orchestrates I/O and calls the pure core +async function processOrder(orderId) { + const order = await db.orders.find(orderId); // I/O + const discounted = applyDiscount(order, 0.1); // pure + await db.orders.save(discounted); // I/O +} +``` + +## Composition as the Primary Tool + +Small functions composed into pipelines replace complex procedures. + +The two composition operators: +- `compose(f, g)(x)` = `f(g(x))` — right-to-left, mathematical convention +- `pipe(f, g)(x)` = `g(f(x))` — left-to-right, reads like a pipeline + +```javascript +// Imperative +function processUser(user) { + const normalized = normalize(user); + const validated = validate(normalized); + const enriched = enrich(validated); + return enriched; +} + +// Functional with pipe +const processUser = pipe(normalize, validate, enrich); +``` + +A pipeline is a description of the transformation. Each step is independently testable and reusable. + +## Totality + +A total function is defined for all possible inputs — it never throws, never returns null, never produces undefined behavior. + +Partial functions (those that crash or return undefined for some inputs) are a source of runtime errors. + +Make functions total by: +- returning `Maybe`/`Option` when a result may not exist +- returning `Either`/`Result` when a computation may fail +- using types that restrict input to valid values + +```typescript +// Partial — crashes on null or negative +function divide(a: number, b: number): number { + return a / b; // Infinity or NaN if b === 0 +} + +// Total — makes the failure explicit in the type +function divide(a: number, b: number): Option { + return b === 0 ? None : Some(a / b); +} +``` + +## Write for Change + +- Prefer designs where change is local. +- Keep pipelines narrow — each function does one transformation. +- Delay abstraction until stable patterns emerge under real use. +- Remove duplication of logic, not just duplication of syntax. diff --git a/skills/fp-best-practices/references/elixir-examples.md b/skills/fp-best-practices/references/elixir-examples.md new file mode 100644 index 0000000..b6bb437 --- /dev/null +++ b/skills/fp-best-practices/references/elixir-examples.md @@ -0,0 +1,348 @@ +# Elixir — Functional Programming Examples + +Concepts covered: pure functions, pipe operator, pattern matching, `with` expression, `{:ok, _}` / `{:error, _}` tuples, immutable data, higher-order functions, structs, side effect boundary. + +Elixir is a functional language on the Erlang VM (BEAM). Designed for concurrency, fault tolerance, and distributed systems. Data is always immutable. + +--- + +## Pure Functions and Referential Transparency + +```elixir +# Elixir values are immutable by default +# Functions return new values — originals are never modified + +defmodule Pricing do + def apply_discount(rate, price), do: price * (1 - rate) + + def add_item(cart, item) do + %{cart | items: [item | cart.items]} + end + # Map update syntax — returns a new map, cart is unchanged +end +``` + +--- + +## Pipe Operator (`|>`) + +```elixir +# |> passes the value on the left as the first argument to the right + +defmodule OrderPipeline do + def process(orders) do + orders + |> Enum.filter(&active?/1) + |> Enum.map(&calculate_total/1) + |> Enum.sum() + end + + defp active?(order), do: order.status == :active + + defp calculate_total(order) do + order.items + |> Enum.map(& &1.price) + |> Enum.sum() + end +end + +# String pipeline +" Alice@Example.COM " +|> String.trim() +|> String.downcase() +|> String.replace(~r/\s+/, "") +# => "alice@example.com" +``` + +--- + +## Currying and Partial Application + +```elixir +# Elixir is not auto-curried, but closures and & capture work for partial application + +# Capture syntax — reference a named function +double = &(&1 * 2) +Enum.map([1, 2, 3], double) # [2, 4, 6] + +# Closure as partial application +apply_discount = fn rate -> fn price -> price * (1 - rate) end end +apply_ten_percent = apply_discount.(0.1) +apply_ten_percent.(200) # 180.0 + +# Using Kernel.then/1 for inline chaining +result = + 100 + |> then(&(&1 * 2)) + |> then(&(&1 + 10)) +# => 210 + +# Higher-order function with configuration +def make_tax_fn(rate) do + fn price -> price * (1 + rate) end +end + +add_vat = make_tax_fn(0.21) +add_vat.(100) # 121.0 +``` + +--- + +## Pattern Matching + +```elixir +# Pattern matching is the primary control flow tool +# Match on structure, not just value + +defmodule UserParser do + def describe_user(user) do + case user do + %{name: name, role: :admin} -> "Admin: #{name}" + %{name: name, active: true} -> "Active user: #{name}" + %{name: name} -> "Inactive: #{name}" + _ -> "Unknown" + end + end + + # Pattern matching in function clauses + def greet(%{name: name, role: :admin}), do: "Hello, admin #{name}!" + def greet(%{name: name}), do: "Hello, #{name}!" + def greet(_), do: "Hello, stranger!" +end + +# Destructuring in function arguments +def order_total(%{items: items}) do + Enum.sum(Enum.map(items, & &1.price)) +end + +# Pin operator — match against an existing value +expected_id = "user-123" +%{id: ^expected_id, name: name} = user # fails unless id matches expected_id +``` + +--- + +## `{:ok, value}` / `{:error, reason}` — Elixir's Either + +```elixir +# Elixir convention: functions return {:ok, value} or {:error, reason} +# This is idiomatic Elixir — no library required + +defmodule Validation do + def validate_email(email) when is_binary(email) do + if String.contains?(email, "@"), + do: {:ok, String.downcase(email)}, + else: {:error, :invalid_email} + end + def validate_email(_), do: {:error, :invalid_email} + + def validate_age(age) when is_integer(age) and age >= 0 and age <= 150, + do: {:ok, age} + def validate_age(_), do: {:error, :invalid_age} +end + +# Case for single Result handling +def process_email(raw) do + case Validation.validate_email(raw) do + {:ok, email} -> IO.puts("Valid: #{email}") + {:error, reason} -> IO.puts("Error: #{reason}") + end +end + +# with — sequential chaining, short-circuits on non-matching clause +def parse_user(email, age) do + with {:ok, valid_email} <- Validation.validate_email(email), + {:ok, valid_age} <- Validation.validate_age(age) do + {:ok, %{email: valid_email, age: valid_age}} + end + # If any clause doesn't match, with returns that value automatically +end + +# with + else for custom error handling +def parse_user(email, age) do + with {:ok, valid_email} <- Validation.validate_email(email), + {:ok, valid_age} <- Validation.validate_age(age) do + {:ok, %{email: valid_email, age: valid_age}} + else + {:error, :invalid_email} -> {:error, "Email must contain @"} + {:error, :invalid_age} -> {:error, "Age must be between 0 and 150"} + end +end +``` + +--- + +## Structs and Immutable Data + +```elixir +defmodule User do + defstruct [:id, :name, :email, address: nil] + + # Struct update — returns a new struct + def update_email(%User{} = user, new_email) do + %{user | email: new_email} + end + + # Nested update (no deep-merge built-in — manual) + def update_city(%User{address: nil} = user, _city), do: user + def update_city(%User{address: address} = user, city) do + %{user | address: %{address | city: city}} + end +end + +# Map update syntax works on any map +user = %{id: "1", name: "Alice", email: "alice@example.com"} +updated = %{user | email: "new@example.com"} +# user is unchanged — updated is a new map + +# Map.update/4 — apply a function to an existing value +cart = %{items: []} +Map.update(cart, :items, [], fn items -> [new_item | items] end) +``` + +--- + +## Higher-Order Functions (Enum and Stream) + +```elixir +orders = [ + %{id: 1, status: :active, total: 150.0}, + %{id: 2, status: :cancelled, total: 80.0}, + %{id: 3, status: :active, total: 200.0} +] + +# Enum.map — transform each element +Enum.map(orders, & &1.total) # [150.0, 80.0, 200.0] + +# Enum.filter — select by predicate +Enum.filter(orders, &(&1.status == :active)) + +# Enum.reduce — fold to a single value +Enum.reduce(orders, 0, fn o, acc -> acc + o.total end) # 430.0 + +# Enum.flat_map — map then flatten (flatMap) +Enum.flat_map(orders, & &1.items) + +# Enum.group_by — partition by key +Enum.group_by(orders, & &1.status) +# %{active: [...], cancelled: [...]} + +# Enum.sort_by — sort by key +Enum.sort_by(orders, & &1.total) +Enum.sort_by(orders, & &1.total, :desc) + +# Enum.zip +Enum.zip(["Alice", "Bob"], [90, 85]) # [{"Alice", 90}, {"Bob", 85}] + +# Stream — lazy evaluation (no intermediate lists) +Stream.unfold(0, fn n -> {n, n + 1} end) # infinite stream of naturals +|> Stream.filter(&Integer.is_even/1) +|> Stream.take(5) +|> Enum.to_list() # [0, 2, 4, 6, 8] +``` + +--- + +## Pattern Matching on Sum Types (tagged tuples and structs) + +```elixir +defmodule OrderStatus do + def describe({:pending, _created_at}), + do: "Awaiting shipment" + + def describe({:shipped, _created_at, tracking_code}), + do: "Shipped — #{tracking_code}" + + def describe({:delivered, _created_at, delivered_at}), + do: "Delivered on #{Date.to_string(delivered_at)}" + + def describe({:cancelled, _created_at, reason}), + do: "Cancelled: #{reason}" +end + +# Usage +OrderStatus.describe({:shipped, ~D[2026-05-01], "TRACK-456"}) +# => "Shipped — TRACK-456" + +# Using structs + protocols for more formal sum types +defprotocol Describable do + def describe(status) +end + +defmodule Pending do defstruct [:created_at] end +defmodule Shipped do defstruct [:created_at, :tracking_code] end +defmodule Cancelled do defstruct [:created_at, :reason] end + +defimpl Describable, for: Pending do + def describe(_), do: "Awaiting shipment" +end + +defimpl Describable, for: Shipped do + def describe(%{tracking_code: code}), do: "Shipped — #{code}" +end +``` + +--- + +## Side Effect Boundary + +```elixir +defmodule Registration do + # Pure core — all logic, no I/O + def process(existing_emails, input, id, now) do + email = input |> Map.get(:email, "") |> String.downcase() |> String.trim() + + cond do + not String.contains?(email, "@") -> + {:error, "Invalid email"} + MapSet.member?(existing_emails, email) -> + {:error, "Email already registered"} + true -> + {:ok, %{id: id, email: email, created_at: now}} + end + end + + # Impure shell — thin I/O coordination + def register(input) do + existing_emails = Db.all_emails() # I/O + id = Ecto.UUID.generate() # I/O (randomness) + now = DateTime.utc_now() # I/O (time) + + case process(existing_emails, input, id, now) do # pure + {:ok, user} -> + Db.save_user(user) # I/O + {:ok, user} + {:error, _} = error -> + error + end + end +end + +# Dependency injection via function arguments (for testing) +def register(input, fetch_emails \\ &Db.all_emails/0, save_user \\ &Db.save_user/1) do + existing = fetch_emails.() + # ... +end +``` + +--- + +## GenServer (stateful process — FP + OTP) + +```elixir +# GenServer separates pure message handling from process lifecycle +defmodule Counter do + use GenServer + + # Pure callbacks — handle state transitions + def handle_call(:get, _from, state), do: {:reply, state, state} + def handle_cast(:increment, state), do: {:noreply, state + 1} + def handle_cast(:reset, _state), do: {:noreply, 0} + + # Client API + def start_link(initial), do: GenServer.start_link(__MODULE__, initial, name: __MODULE__) + def get, do: GenServer.call(__MODULE__, :get) + def increment, do: GenServer.cast(__MODULE__, :increment) + def reset, do: GenServer.cast(__MODULE__, :reset) +end +``` diff --git a/skills/fp-best-practices/references/elm-examples.md b/skills/fp-best-practices/references/elm-examples.md new file mode 100644 index 0000000..8efde62 --- /dev/null +++ b/skills/fp-best-practices/references/elm-examples.md @@ -0,0 +1,344 @@ +# Elm — Functional Programming Examples + +Concepts covered: pure functions, pipe operator, Maybe, Result, immutable records, higher-order functions, sum types (custom types), The Elm Architecture (TEA), no runtime exceptions. + +Elm is a purely functional language for frontend. Side effects are modeled as commands and subscriptions managed by the Elm runtime — no explicit IO monad needed. + +--- + +## Pure Functions and No Runtime Exceptions + +```elm +-- All Elm functions are pure +-- There are no exceptions: impossible states are prevented by types + +applyDiscount : Float -> Float -> Float +applyDiscount rate price = + price * (1 - rate) + +-- Elm has no null — use Maybe instead +safeDivide : Float -> Float -> Maybe Float +safeDivide _ 0 = Nothing +safeDivide a b = Just (a / b) +``` + +--- + +## Pipeline Operator (`|>`) + +```elm +-- |> passes the value on the left as the last argument to the function on the right +processOrder : List Order -> Float +processOrder orders = + orders + |> List.filter isActive + |> List.map orderTotal + |> List.sum + +-- Composition operators +-- (<<) right-to-left: (f << g) x = f (g x) +-- (>>) left-to-right: (f >> g) x = g (f x) + +normalizeEmail : String -> String +normalizeEmail = + String.trim >> String.toLower +``` + +--- + +## Currying (built-in) + +```elm +-- All Elm functions are automatically curried +-- add : Int -> Int -> Int is Int -> (Int -> Int) + +add : Int -> Int -> Int +add x y = x + y + +add5 : Int -> Int +add5 = add 5 -- partial application + +-- Sections with operators +double : List Int -> List Int +double = List.map ((*) 2) + +greaterThan10 : List Int -> List Int +greaterThan10 = List.filter (\x -> x > 10) +``` + +--- + +## Maybe — Explicit Absence + +```elm +-- type Maybe a = Nothing | Just a + +findUser : UserId -> Dict UserId User -> Maybe User +findUser id db = + Dict.get id db + +-- Pattern matching — exhaustive +describeUser : Maybe User -> String +describeUser maybeUser = + case maybeUser of + Nothing -> + "User not found" + Just user -> + "Found: " ++ user.name + +-- Maybe.map — transform the value if present +getEmail : Maybe User -> Maybe String +getEmail maybeUser = + Maybe.map .email maybeUser + +-- Maybe.andThen — chain optional computations (like flatMap) +getCity : User -> Maybe String +getCity user = + user.address + |> Maybe.andThen .city + +-- withDefault — provide a fallback +displayCity : User -> String +displayCity user = + getCity user + |> Maybe.withDefault "Unknown city" +``` + +--- + +## Result — Explicit Failure + +```elm +-- type Result error value = Err error | Ok value + +type ValidationError + = InvalidEmail + | InvalidAge + +validateEmail : String -> Result ValidationError String +validateEmail email = + if String.contains "@" email then + Ok (String.toLower email) + else + Err InvalidEmail + +validateAge : Int -> Result ValidationError Int +validateAge age = + if age >= 0 && age <= 150 then + Ok age + else + Err InvalidAge + +-- Result.andThen — chain dependent validations +parseUser : String -> Int -> Result ValidationError User +parseUser email age = + validateEmail email + |> Result.andThen + (\validEmail -> + validateAge age + |> Result.map (\validAge -> { email = validEmail, age = validAge }) + ) + +-- Result.map2 — combine two independent Results +parseUser2 : String -> Int -> Result ValidationError User +parseUser2 email age = + Result.map2 (\e a -> { email = e, age = a }) + (validateEmail email) + (validateAge age) + +-- Pattern match at the view/update boundary +viewResult : Result ValidationError User -> Html msg +viewResult result = + case result of + Err InvalidEmail -> + text "Please enter a valid email" + Err InvalidAge -> + text "Age must be between 0 and 150" + Ok user -> + text ("Welcome, " ++ user.email) +``` + +--- + +## Custom Types — Sum Types + +```elm +-- Custom types are sum types with pattern matching +type OrderStatus + = Pending + | Shipped String -- carries tracking code + | Delivered Time.Posix + | Cancelled String -- carries reason + +describeStatus : OrderStatus -> String +describeStatus status = + case status of + Pending -> + "Awaiting shipment" + Shipped trackingCode -> + "Shipped — " ++ trackingCode + Delivered deliveredAt -> + "Delivered on " ++ formatTime deliveredAt + Cancelled reason -> + "Cancelled: " ++ reason + +-- Recursive custom type +type Tree a + = Leaf + | Node a (Tree a) (Tree a) + +depth : Tree a -> Int +depth tree = + case tree of + Leaf -> + 0 + Node _ left right -> + 1 + max (depth left) (depth right) +``` + +--- + +## Immutable Records + +```elm +type alias User = + { id : String + , name : String + , email : String + , address : Maybe Address + } + +type alias Address = + { street : String + , city : String + } + +-- Record update syntax — creates a new record, original unchanged +updateEmail : User -> String -> User +updateEmail user newEmail = + { user | email = newEmail } + +-- Nested update (manual spreading required) +updateCity : User -> String -> User +updateCity user city = + case user.address of + Nothing -> + user + Just addr -> + { user | address = Just { addr | city = city } } +``` + +--- + +## Higher-Order Functions + +```elm +-- List.map :: (a -> b) -> List a -> List b +doubled : List Int +doubled = List.map ((*) 2) [ 1, 2, 3 ] -- [2, 4, 6] + +-- List.filter :: (a -> Bool) -> List a -> List a +activeOrders : List Order -> List Order +activeOrders = List.filter .active + +-- List.foldl :: (a -> b -> b) -> b -> List a -> b +total : List Order -> Float +total = List.foldl (\order acc -> acc + order.total) 0.0 + +-- List.concatMap — flatMap equivalent +allTags : List Post -> List String +allTags = List.concatMap .tags + +-- List.indexedMap — map with index +withIndex : List a -> List ( Int, a ) +withIndex = List.indexedMap Tuple.pair + +-- Dict.map, Dict.filter — work on dictionaries +activeCounts : Dict String Int -> Dict String Int +activeCounts = Dict.filter (\_ count -> count > 0) +``` + +--- + +## The Elm Architecture (TEA) — Side Effect Boundary + +```elm +-- Side effects are commands (Cmd) and subscriptions (Sub) +-- Pure update function — testable without I/O +type Msg + = EmailChanged String + | SubmitForm + | UserRegistered (Result Http.Error User) + +type alias Model = + { email : String + , status : Status + } + +type Status + = Idle + | Loading + | Success User + | Failure String + +-- Pure — no I/O, returns new model + a command (effect description) +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case msg of + EmailChanged email -> + ( { model | email = email }, Cmd.none ) + + SubmitForm -> + ( { model | status = Loading } + , registerUser model.email -- produces a Cmd, not a real HTTP call + ) + + UserRegistered (Ok user) -> + ( { model | status = Success user }, Cmd.none ) + + UserRegistered (Err _) -> + ( { model | status = Failure "Registration failed" }, Cmd.none ) + +-- HTTP command builder (pure description of an effect) +registerUser : String -> Cmd Msg +registerUser email = + Http.post + { url = "/api/users" + , body = Http.jsonBody (encodeEmail email) + , expect = Http.expectJson UserRegistered userDecoder + } +``` + +--- + +## Decoder Pattern — Safe JSON Parsing + +```elm +import Json.Decode as Decode exposing (Decoder) + +type alias User = + { id : String + , name : String + , email : String + } + +-- Decoder is a description of how to parse JSON — pure and composable +userDecoder : Decoder User +userDecoder = + Decode.map3 User + (Decode.field "id" Decode.string) + (Decode.field "name" Decode.string) + (Decode.field "email" Decode.string) + +-- Decode.andThen for validated parsing +ageDecoder : Decoder Int +ageDecoder = + Decode.int + |> Decode.andThen + (\age -> + if age >= 0 && age <= 150 then + Decode.succeed age + else + Decode.fail "Age out of range" + ) +``` diff --git a/skills/fp-best-practices/references/fsharp-examples.md b/skills/fp-best-practices/references/fsharp-examples.md new file mode 100644 index 0000000..abf8d7e --- /dev/null +++ b/skills/fp-best-practices/references/fsharp-examples.md @@ -0,0 +1,300 @@ +# F# — Functional Programming Examples + +Concepts covered: pure functions, pipe operator, currying, Option, Result, immutable records, higher-order functions, discriminated unions, computation expressions, side effect boundary. + +F# is a functional-first language on .NET. It runs on the CLR and interoperates with C# and the full .NET ecosystem. + +--- + +## Pure Functions and Referential Transparency + +```fsharp +// All F# values are immutable by default +// let bindings cannot be reassigned (use mutable keyword to opt in, rarely needed) + +let applyDiscount (rate: float) (price: float) : float = + price * (1.0 - rate) + +// Referentially transparent — (applyDiscount 0.1 100.0) = 90.0, always + +// Record — immutable product type +type Cart = { Items: Item list; CouponCode: string option } + +let addItem (cart: Cart) (item: Item) : Cart = + { cart with Items = item :: cart.Items } +// with expression — creates a new Cart, original unchanged +``` + +--- + +## Pipe Operator (`|>`) and Composition (`>>`) + +```fsharp +// |> — pipes the value on the left as the last argument to the right +let processOrder (orders: Order list) : float = + orders + |> List.filter (fun o -> o.Active) + |> List.map (fun o -> o.Total) + |> List.sum + +// >> — right-to-left composition (f >> g = g(f(x))) +let normalizeEmail : string -> string = + (fun s -> s.Trim()) >> (fun s -> s.ToLower()) + +// Multiple pipeline steps with named helpers +let filterActive = List.filter (fun o -> o.Active) +let calculateTotals = List.map (fun o -> o.Total) +let grandTotal = List.sum + +let summarize = filterActive >> calculateTotals >> grandTotal +``` + +--- + +## Currying (built-in) + +```fsharp +// All F# functions are automatically curried +// add : int -> int -> int is really int -> (int -> int) + +let add (x: int) (y: int) : int = x + y + +let add5 : int -> int = add 5 // partial application — no special syntax + +// Using partial application with pipeline +let applyDiscount (rate: float) (price: float) = price * (1.0 - rate) +let applyTenPercent = applyDiscount 0.1 + +[100.0; 200.0; 300.0] |> List.map applyTenPercent // [90.0; 180.0; 270.0] +``` + +--- + +## Option — Explicit Absence + +```fsharp +// type Option<'a> = None | Some of 'a + +let findUser (id: string) (db: Map) : User option = + Map.tryFind id db + +// Pattern matching — must handle both cases +let describeUser (maybeUser: User option) : string = + match maybeUser with + | None -> "User not found" + | Some user -> sprintf "Found: %s" user.Name + +// Option.map — transform if present +let getEmail (maybeUser: User option) : string option = + Option.map (fun u -> u.Email) maybeUser +// or: maybeUser |> Option.map (fun u -> u.Email) + +// Option.bind — chain optional computations (flatMap) +let getCity (user: User) : string option = + user.Address |> Option.bind (fun addr -> addr.City) + +// Option.defaultValue — provide a fallback +let displayCity (user: User) : string = + getCity user |> Option.defaultValue "Unknown city" + +// Computation expression for Option (with the FSharpPlus library) +let getCity user = option { + let! address = user.Address + let! city = address.City + return city +} +``` + +--- + +## Result — Explicit Failure + +```fsharp +// type Result<'T,'E> = Ok of 'T | Error of 'E + +type ValidationError = + | InvalidEmail + | InvalidAge + +type User = { Email: string; Age: int } + +let validateEmail (email: string) : Result = + if email.Contains("@") then Ok (email.ToLower()) + else Error InvalidEmail + +let validateAge (age: int) : Result = + if age >= 0 && age <= 150 then Ok age + else Error InvalidAge + +// result computation expression — chains Result values, short-circuits on Error +let parseUser (email: string) (age: int) : Result = + result { + let! validEmail = validateEmail email + let! validAge = validateAge age + return { Email = validEmail; Age = validAge } + } + +// Pattern match at the call site +let showResult (res: Result) : string = + match res with + | Error InvalidEmail -> "Bad email" + | Error InvalidAge -> "Bad age" + | Ok user -> sprintf "User: %s" user.Email + +// Result.map and Result.bind for pipeline style +let processEmail email = + email + |> validateEmail + |> Result.bind (fun e -> if e.Length > 5 then Ok e else Error InvalidEmail) + |> Result.map (fun e -> e.ToUpperInvariant()) +``` + +--- + +## Discriminated Unions — Sum Types + +```fsharp +// Discriminated union — exactly one variant at a time +type OrderStatus = + | Pending + | Shipped of trackingCode: string + | Delivered of deliveredAt: System.DateTime + | Cancelled of reason: string + +// Exhaustive pattern matching — compiler warns on missing cases +let describeStatus (status: OrderStatus) : string = + match status with + | Pending -> "Awaiting shipment" + | Shipped code -> sprintf "Shipped — %s" code + | Delivered delivAt -> sprintf "Delivered on %s" (delivAt.ToShortDateString()) + | Cancelled reason -> sprintf "Cancelled: %s" reason + +// Recursive discriminated union +type Tree<'a> = + | Leaf + | Node of value: 'a * left: Tree<'a> * right: Tree<'a> + +let rec depth (tree: Tree<'a>) : int = + match tree with + | Leaf -> 0 + | Node (_, l, r) -> 1 + max (depth l) (depth r) +``` + +--- + +## Immutable Records + +```fsharp +type Address = { Street: string; City: string } +type User = { Id: string; Name: string; Email: string; Address: Address option } + +// Record copy-and-update expression +let updateEmail (user: User) (newEmail: string) : User = + { user with Email = newEmail } + +// Nested update +let updateCity (user: User) (city: string) : User = + match user.Address with + | None -> user + | Some address -> { user with Address = Some { address with City = city } } + +// Single-case DU for nominal typing (equivalent to branded types in TS) +type UserId = UserId of string +type OrderId = OrderId of string + +let makeUserId (id: string) : UserId = UserId id +let makeOrderId (id: string) : OrderId = OrderId id + +let getUser (UserId id) = db.Users.FindById(id) + +let orderId = makeOrderId "order-123" +// getUser orderId — type error! OrderId ≠ UserId +``` + +--- + +## Higher-Order Functions + +```fsharp +// List.map :: ('a -> 'b) -> 'a list -> 'b list +let doubled = List.map ((*) 2) [1; 2; 3] // [2; 4; 6] + +// List.filter :: ('a -> bool) -> 'a list -> 'a list +let evens = List.filter (fun n -> n % 2 = 0) [1..10] // [2; 4; 6; 8; 10] + +// List.fold :: ('b -> 'a -> 'b) -> 'b -> 'a list -> 'b +let total = List.fold (+) 0 [1; 2; 3; 4; 5] // 15 + +// List.collect — flatMap +let allTags = posts |> List.collect (fun p -> p.Tags) + +// List.groupBy :: ('a -> 'Key) -> 'a list -> ('Key * 'a list) list +let byStatus = orders |> List.groupBy (fun o -> o.Status) + +// List.sortBy :: ('a -> 'Key) -> 'a list -> 'a list +let sorted = orders |> List.sortBy (fun o -> o.Total) +let sortedDesc = orders |> List.sortByDescending (fun o -> o.Total) + +// Seq — lazy evaluation (infinite sequences possible) +let naturals = Seq.initInfinite id +let firstTenEvens = naturals |> Seq.filter (fun n -> n % 2 = 0) |> Seq.take 10 +``` + +--- + +## Side Effect Boundary + +```fsharp +open System + +// Pure core — all logic, no I/O +let processRegistration + (existingEmails: Set) + (input: RegistrationInput) + (id: string) + (now: DateTime) + : Result = + let email = input.Email.ToLower().Trim() + if not (email.Contains("@")) then + Error "Invalid email" + elif Set.contains email existingEmails then + Error "Email already registered" + else + Ok { Id = id; Email = email; CreatedAt = now } + +// Impure shell — thin I/O layer +let registerUser (input: RegistrationInput) : Async> = + async { + let! existingEmails = db.Users.AllEmails() // I/O + let id = Guid.NewGuid().ToString() // I/O + let now = DateTime.UtcNow // I/O + let result = processRegistration existingEmails input id now // pure + match result with + | Ok user -> do! db.Users.Save(user) // I/O + | Error _ -> () + return result + } +``` + +--- + +## Async Computation Expression + +```fsharp +// async {} desugars to Async<'T> — similar to Promise or IO +let fetchAndProcess (userId: string) : Async = + async { + let! user = db.Users.FindById(userId) // await — non-blocking + let! profile = db.Profiles.FindByUserId(userId) + return sprintf "%s (%s)" user.Name profile.City + } + +// Railway-oriented programming with Result + async +let registerAsync input = async { + let! emails = db.AllEmails() + return + input + |> validateInput // Result + |> Result.bind (buildUser emails) // Result +} +``` diff --git a/skills/fp-best-practices/references/function-composition.md b/skills/fp-best-practices/references/function-composition.md new file mode 100644 index 0000000..ce08223 --- /dev/null +++ b/skills/fp-best-practices/references/function-composition.md @@ -0,0 +1,202 @@ +# Function Composition + +Use this reference when designing pipelines, applying currying or partial application, or deciding between explicit and point-free style. + +## Compose and Pipe + +`compose` and `pipe` are the two core operators for building pipelines from small functions. + +- `compose(f, g)(x)` → `f(g(x))` — right-to-left (mathematical order) +- `pipe(f, g)(x)` → `g(f(x))` — left-to-right (reading order) + +```javascript +// Manual composition — hard to read at scale +const result = formatCurrency(applyTax(calculateSubtotal(activeItems))); + +// compose — right-to-left, mathematical style +const summarize = compose(formatCurrency, applyTax, calculateSubtotal); +summarize(activeItems); + +// pipe — left-to-right, reads as a pipeline +const summarize = pipe(calculateSubtotal, applyTax, formatCurrency); +summarize(activeItems); +``` + +Most teams prefer `pipe` because it reads in the direction of execution. Use whichever matches your codebase convention. + +### Implementation + +```javascript +const pipe = (...fns) => x => fns.reduce((v, f) => f(v), x); +const compose = (...fns) => x => fns.reduceRight((v, f) => f(v), x); +``` + +Libraries: Ramda (`R.pipe`, `R.compose`), fp-ts (`pipe`, `flow`), Lodash/FP. + +## Currying + +A curried function takes arguments one at a time and returns a new function for each remaining argument: + +```javascript +// Uncurried +const add = (a, b) => a + b; +add(2, 3); // 5 + +// Curried +const add = a => b => a + b; +add(2)(3); // 5 +const add2 = add(2); // partially applied — waits for b +add2(3); // 5 +add2(10); // 12 +``` + +Currying enables partial application: fix some arguments now, supply the rest at the call site. + +### Auto-curry with Ramda + +```javascript +import * as R from "ramda"; + +const multiply = R.curry((factor, value) => value * factor); +const double = multiply(2); +const triple = multiply(3); + +[1, 2, 3].map(double); // [2, 4, 6] +[1, 2, 3].map(triple); // [3, 6, 9] +``` + +## Partial Application + +Partial application fixes a subset of a function's arguments, producing a function with fewer parameters: + +```javascript +const applyDiscount = (rate, price) => price * (1 - rate); + +// Partially apply the rate +const applyTenPercent = applyDiscount.bind(null, 0.1); +const applyTwentyPercent = applyDiscount.bind(null, 0.2); + +// With Ramda +const applyDiscount = R.curry((rate, price) => price * (1 - rate)); +const applyTenPercent = applyDiscount(0.1); +``` + +Partial application creates specialized functions without repeating arguments at every call site. + +## Point-Free Style + +A point-free (tacit) function is defined without explicitly mentioning the data it operates on: + +```javascript +// Explicit style — data (`users`) is visible +const activeUsers = users => users.filter(u => u.active); + +// Point-free — no explicit data argument +const activeUsers = R.filter(R.prop("active")); +``` + +Point-free reads well when the pipeline is the main story. It becomes a problem when the level of abstraction is hard to track. + +### When to use point-free + +Use point-free when: +- each step in a pipeline is a named function with a clear domain meaning +- removing the data argument makes the pipeline cleaner +- the reader can understand the transformation without the data parameter as a hint + +Avoid point-free when: +- the function composition is deeply nested or complex +- readers need the data name to understand what flows through + +## Pipeline Example End-to-End + +### Problem: summarize active orders + +```javascript +// Imperative +function summarize(orders) { + const active = orders.filter(o => o.status === "active"); + const totals = active.map(o => o.items.reduce((sum, i) => sum + i.price, 0)); + const grandTotal = totals.reduce((sum, t) => sum + t, 0); + return { count: active.length, grandTotal }; +} + +// Functional with pipe +const sumPrices = items => items.reduce((sum, i) => sum + i.price, 0); +const sumAll = nums => nums.reduce((a, b) => a + b, 0); + +const summarize = (orders) => { + const active = orders.filter(o => o.status === "active"); + const totals = active.map(sumPrices); + return { count: active.length, grandTotal: sumAll(totals) }; +}; +``` + +## Monadic Chaining (flatMap / chain) + +When each step in a pipeline can fail or produce a wrapped value, use `flatMap`/`chain` instead of `map`: + +```javascript +// With Promise (async pipeline) +fetch("/api/orders") + .then(parseJSON) // returns Promise + .then(filterActive) // returns Promise + .then(sumTotals) // returns Promise + .catch(handleError); + +// With Either (synchronous, explicit errors) +import { pipe } from "fp-ts/function"; +import * as E from "fp-ts/Either"; + +const processOrder = pipe( + parseOrder(rawInput), // Either + E.chain(validateOrder), // Either + E.chain(applyDiscount), // Either + E.map(formatForResponse) // Either +); +``` + +`flatMap`/`chain` = `map` + `flatten`. It sequences computations where each step wraps its result. + +## Haskell Style + +```haskell +-- Composition with (.) operator (right-to-left) +summarize :: [Order] -> Summary +summarize = buildSummary . sumTotals . map orderTotal . filter isActive + +-- Pipeline with (&) operator (left-to-right) +summarize orders = orders + & filter isActive + & map orderTotal + & sum + & buildSummary +``` + +## Elixir Style (|> pipeline operator) + +```elixir +def summarize(orders) do + orders + |> Enum.filter(&active?/1) + |> Enum.map(&order_total/1) + |> Enum.sum() +end +``` + +## F# Style (|> pipeline operator) + +```fsharp +let summarize orders = + orders + |> List.filter isActive + |> List.map orderTotal + |> List.sum +``` + +## Common Mistakes + +- **Side effects inside a pipeline** — any step that mutates or performs I/O breaks composition guarantees. Extract it to the shell. +- **Too many arguments per step** — a step that takes 3+ arguments may need partial application or a config object. +- **Nesting pipelines** — if you need a pipeline inside a pipeline step, extract the inner pipeline to a named function. +- **Point-free with complex combinators** — when composition + map + filter are combined without names, extract intermediate steps. diff --git a/skills/fp-best-practices/references/go-examples.md b/skills/fp-best-practices/references/go-examples.md new file mode 100644 index 0000000..aa3afc0 --- /dev/null +++ b/skills/fp-best-practices/references/go-examples.md @@ -0,0 +1,417 @@ +# Go — Functional Programming Examples + +Concepts covered: pure functions, first-class functions, closures, error handling (`error` return), functional options, generics (1.18+), immutable value types, higher-order functions, iterator pattern. + +Go is not a functional language, but it supports pure functions, first-class functions, closures, and immutable value types. FP idioms in Go stay idiomatic — avoid over-abstracting. + +--- + +## Pure Functions and Value Semantics + +```go +package pricing + +// Pure — same input always gives same output, no side effects +func ApplyDiscount(rate, price float64) float64 { + return price * (1 - rate) +} + +// Pure — structs are value types; assignment copies, not references +type Cart struct { + Items []Item +} + +// Returns a new Cart — original is unchanged because we copy the struct +// NOTE: the Items slice still shares underlying array; use append for safety +func AddItem(cart Cart, item Item) Cart { + newItems := make([]Item, len(cart.Items)+1) + copy(newItems, cart.Items) + newItems[len(cart.Items)] = item + return Cart{Items: newItems} +} +``` + +--- + +## First-Class Functions and Closures + +```go +package main + +// Functions as values +type Predicate[T any] func(T) bool +type Transform[A, B any] func(A) B + +// Closure — captures surrounding scope +func MakeAdder(n int) func(int) int { + return func(x int) int { + return x + n + } +} + +add5 := MakeAdder(5) +add5(10) // 15 + +// Closure for dependency injection +func MakeUserService(db Database) func(id string) (User, error) { + return func(id string) (User, error) { + return db.FindUser(id) + } +} + +// Partial application via closure +func MakeDiscountFn(rate float64) func(float64) float64 { + return func(price float64) float64 { + return price * (1 - rate) + } +} + +applyTenPercent := MakeDiscountFn(0.1) +applyTenPercent(200) // 180.0 +``` + +--- + +## Error Handling — Go's Either + +```go +package validation + +import ( + "fmt" + "strings" +) + +// Go's idiomatic (value, error) is the Either pattern +// error = Left, value = Right + +func ValidateEmail(email string) (string, error) { + email = strings.TrimSpace(strings.ToLower(email)) + if !strings.Contains(email, "@") { + return "", fmt.Errorf("invalid email: %q", email) + } + return email, nil +} + +func ValidateAge(age int) (int, error) { + if age < 0 || age > 150 { + return 0, fmt.Errorf("age must be between 0 and 150, got %d", age) + } + return age, nil +} + +type User struct { + Email string + Age int +} + +// Chain validations — early return on first error +func ParseUser(email string, age int) (User, error) { + validEmail, err := ValidateEmail(email) + if err != nil { + return User{}, err + } + validAge, err := ValidateAge(age) + if err != nil { + return User{}, err + } + return User{Email: validEmail, Age: validAge}, nil +} + +// Usage +user, err := ParseUser("alice@example.com", 30) +if err != nil { + log.Printf("validation failed: %v", err) + return +} +fmt.Printf("Valid user: %+v\n", user) +``` + +--- + +## Functional Options Pattern + +```go +package server + +// Functional options: configure a struct without a bloated constructor +// Robert Pike's pattern — idiomatic Go FP + +type Server struct { + host string + port int + timeout time.Duration + maxConn int +} + +type Option func(*Server) + +func WithHost(host string) Option { + return func(s *Server) { s.host = host } +} + +func WithPort(port int) Option { + return func(s *Server) { s.port = port } +} + +func WithTimeout(d time.Duration) Option { + return func(s *Server) { s.timeout = d } +} + +func NewServer(opts ...Option) *Server { + s := &Server{ + host: "localhost", + port: 8080, + timeout: 30 * time.Second, + maxConn: 100, + } + for _, opt := range opts { + opt(s) + } + return s +} + +// Usage +srv := NewServer( + WithHost("0.0.0.0"), + WithPort(9000), + WithTimeout(60 * time.Second), +) +``` + +--- + +## Generics and Higher-Order Functions (Go 1.18+) + +```go +package slicefn + +// Map :: (A -> B) -> []A -> []B +func Map[A, B any](slice []A, fn func(A) B) []B { + result := make([]B, len(slice)) + for i, v := range slice { + result[i] = fn(v) + } + return result +} + +// Filter :: (A -> bool) -> []A -> []A +func Filter[A any](slice []A, pred func(A) bool) []A { + var result []A + for _, v := range slice { + if pred(v) { + result = append(result, v) + } + } + return result +} + +// Reduce :: (B -> A -> B) -> B -> []A -> B +func Reduce[A, B any](slice []A, init B, fn func(B, A) B) B { + acc := init + for _, v := range slice { + acc = fn(acc, v) + } + return acc +} + +// FlatMap :: (A -> []B) -> []A -> []B +func FlatMap[A, B any](slice []A, fn func(A) []B) []B { + var result []B + for _, v := range slice { + result = append(result, fn(v)...) + } + return result +} + +// Usage +type Order struct { + ID string + Active bool + Total float64 + Items []Item +} + +orders := []Order{...} + +activeOrders := Filter(orders, func(o Order) bool { return o.Active }) +totals := Map(activeOrders, func(o Order) float64 { return o.Total }) +grandTotal := Reduce(totals, 0.0, func(acc, t float64) float64 { return acc + t }) +allItems := FlatMap(orders, func(o Order) []Item { return o.Items }) +``` + +--- + +## Standard Library — `slices` and `maps` (Go 1.21+) + +```go +import ( + "cmp" + "slices" + "maps" +) + +type Order struct { + ID string + Total float64 + Status string +} + +orders := []Order{...} + +// Sort by field +slices.SortFunc(orders, func(a, b Order) int { + return cmp.Compare(a.Total, b.Total) +}) + +// Check if any/all satisfy a predicate +hasActive := slices.ContainsFunc(orders, func(o Order) bool { return o.Status == "active" }) + +// Collect map keys +keys := slices.Collect(maps.Keys(myMap)) +``` + +--- + +## Immutability via Value Types + +```go +// Structs are value types — assignment copies +type Point struct { + X, Y float64 +} + +p1 := Point{1.0, 2.0} +p2 := p1 // p2 is a copy — changing p2 does not affect p1 +p2.X = 99.0 +fmt.Println(p1) // {1 2} — unchanged + +// For immutable domain values, avoid pointer receivers in pure functions +func (p Point) Translate(dx, dy float64) Point { + return Point{p.X + dx, p.Y + dy} // new Point — p unchanged +} + +// Use pointer only for mutation or performance with large structs +// For domain value objects, prefer value receivers +type Money struct { + Amount int64 + Currency string +} + +func (m Money) Add(other Money) Money { + if m.Currency != other.Currency { + panic("currency mismatch") + } + return Money{Amount: m.Amount + other.Amount, Currency: m.Currency} +} +``` + +--- + +## Function Pipelines with Variadic Functions + +```go +// Pipeline via slice of functions (when types align) +type Middleware func(http.Handler) http.Handler + +func Chain(middlewares ...Middleware) Middleware { + return func(final http.Handler) http.Handler { + for i := len(middlewares) - 1; i >= 0; i-- { + final = middlewares[i](final) + } + return final + } +} + +// Generic transform pipeline +type Step[T any] func(T) (T, error) + +func Pipeline[T any](steps ...Step[T]) Step[T] { + return func(input T) (T, error) { + current := input + for _, step := range steps { + next, err := step(current) + if err != nil { + return current, err + } + current = next + } + return current, nil + } +} + +// Usage +process := Pipeline( + normalizeInput, + validateInput, + enrichInput, +) +result, err := process(rawInput) +``` + +--- + +## Side Effect Boundary + +```go +package registration + +// Pure core — no I/O, returns (value, error) +func ProcessRegistration( + existingEmails map[string]bool, + email, name, id string, +) (User, error) { + email = strings.TrimSpace(strings.ToLower(email)) + if !strings.Contains(email, "@") { + return User{}, errors.New("invalid email") + } + if existingEmails[email] { + return User{}, errors.New("email already registered") + } + return User{ID: id, Email: email, Name: name}, nil +} + +// Impure shell — only coordinates I/O +func RegisterUser(ctx context.Context, db DB, email, name string) (User, error) { + existing, err := db.AllEmails(ctx) // I/O + if err != nil { + return User{}, err + } + id := uuid.New().String() // I/O (randomness) + user, err := ProcessRegistration(existing, email, name, id) // pure + if err != nil { + return User{}, err + } + if err := db.SaveUser(ctx, user); err != nil { // I/O + return User{}, err + } + return user, nil +} +``` + +--- + +## Iterator Pattern (Go 1.23 range-over-func) + +```go +// Go 1.23 — range over iterator functions +import "iter" + +// Producer that yields values +func ActiveOrders(orders []Order) iter.Seq[Order] { + return func(yield func(Order) bool) { + for _, o := range orders { + if o.Active { + if !yield(o) { + return // consumer stopped iteration + } + } + } + } +} + +// Usage with range +for order := range ActiveOrders(orders) { + fmt.Println(order.ID) +} +``` diff --git a/skills/fp-best-practices/references/haskell-examples.md b/skills/fp-best-practices/references/haskell-examples.md new file mode 100644 index 0000000..aa180ec --- /dev/null +++ b/skills/fp-best-practices/references/haskell-examples.md @@ -0,0 +1,297 @@ +# Haskell — Functional Programming Examples + +Concepts covered: pure functions, function composition, currying, Maybe, Either, immutable records, higher-order functions, sum types, IO boundary, typeclasses. + +Haskell is a purely functional language — all these patterns are idiomatic, not add-ons. + +--- + +## Pure Functions and Referential Transparency + +```haskell +-- All Haskell functions are pure by default +-- A function cannot access external state unless IO is in its type + +applyDiscount :: Double -> Double -> Double +applyDiscount rate price = price * (1 - rate) + +-- Referentially transparent — can substitute call with its result +-- applyDiscount 0.1 100.0 ≡ 90.0 everywhere + +addItem :: Cart -> Item -> Cart +addItem cart item = cart { items = item : items cart } +-- Record update syntax — produces a new Cart, original unchanged +``` + +--- + +## Function Composition + +```haskell +-- (.) operator — right-to-left composition +processEmail :: String -> String +processEmail = formatForDisplay . validate . normalize + where + normalize = map toLower . strip + validate e = if '@' `elem` e then e else error "Invalid" + formatForDisplay e = "<" ++ e ++ ">" + +-- (&) operator — left-to-right (like pipe) +processOrder :: [Order] -> Summary +processOrder orders = orders + & filter isActive + & map orderTotal + & sum + & buildSummary + +-- ($) — function application, avoids parentheses +-- map double $ filter even [1..10] +-- is equivalent to: map double (filter even [1..10]) +``` + +--- + +## Currying (built into the language) + +```haskell +-- Every function in Haskell is curried by default +-- add :: Int -> Int -> Int is actually Int -> (Int -> Int) +add :: Int -> Int -> Int +add x y = x + y + +add5 :: Int -> Int +add5 = add 5 -- partial application — no special syntax needed + +-- Sections: partially apply an operator +double :: [Int] -> [Int] +double = map (* 2) + +greaterThan10 :: [Int] -> [Int] +greaterThan10 = filter (> 10) + +-- Flip argument order +flippedDiv :: Int -> Int -> Int +flippedDiv = flip div +-- flippedDiv 2 10 = 10 `div` 2 = 5 +``` + +--- + +## Maybe — Explicit Absence + +```haskell +-- Maybe is built into Prelude +-- data Maybe a = Nothing | Just a + +findUser :: UserId -> Maybe User +findUser uid = Map.lookup uid userDatabase + +-- Pattern matching — must handle both cases +describeUser :: UserId -> String +describeUser uid = + case findUser uid of + Nothing -> "User not found" + Just user -> "Found: " ++ userName user + +-- Functor — map over the value inside Maybe +getEmail :: UserId -> Maybe String +getEmail uid = fmap userEmail (findUser uid) +-- or: userEmail <$> findUser uid + +-- Monad — chain Maybe computations (short-circuits on Nothing) +getCity :: UserId -> Maybe String +getCity uid = do + user <- findUser uid + address <- userAddress user + city <- addressCity address + return city + +-- With >>= (bind) — same as do-notation +getCity' :: UserId -> Maybe String +getCity' uid = findUser uid >>= userAddress >>= addressCity + +-- fromMaybe — provide a default +displayCity :: UserId -> String +displayCity uid = fromMaybe "Unknown city" (getCity uid) +``` + +--- + +## Either — Explicit Failure + +```haskell +-- data Either e a = Left e | Right a +-- Left = error, Right = success (mnemonic: "right" = correct) + +data ValidationError = InvalidEmail | InvalidAge deriving (Show) +data User = User { email :: String, age :: Int } deriving (Show) + +validateEmail :: String -> Either ValidationError String +validateEmail email + | '@' `elem` email = Right (map toLower email) + | otherwise = Left InvalidEmail + +validateAge :: Int -> Either ValidationError Int +validateAge age + | age >= 0 && age <= 150 = Right age + | otherwise = Left InvalidAge + +-- do-notation chains Either (short-circuits on Left) +parseUser :: String -> Int -> Either ValidationError User +parseUser rawEmail rawAge = do + validEmail <- validateEmail rawEmail + validAge <- validateAge rawAge + return (User validEmail validAge) + +-- Pattern matching at the call site +showResult :: Either ValidationError User -> String +showResult (Left InvalidEmail) = "Bad email" +showResult (Left InvalidAge) = "Bad age" +showResult (Right user) = "User: " ++ email user +``` + +--- + +## Sum Types — Algebraic Data Types + +```haskell +-- Sum type: a value is exactly one variant +data OrderStatus + = Pending { createdAt :: UTCTime } + | Shipped { createdAt :: UTCTime, trackingCode :: String } + | Delivered { createdAt :: UTCTime, deliveredAt :: UTCTime } + | Cancelled { createdAt :: UTCTime, reason :: String } + +-- Pattern matching is exhaustive — GHC warns if a case is missing +describeStatus :: OrderStatus -> String +describeStatus (Pending _) = "Awaiting shipment" +describeStatus (Shipped _ code) = "Shipped: " ++ code +describeStatus (Delivered _ delAt) = "Delivered: " ++ show delAt +describeStatus (Cancelled _ reason) = "Cancelled: " ++ reason + +-- Recursive sum type +data Tree a = Leaf | Node a (Tree a) (Tree a) + +depth :: Tree a -> Int +depth Leaf = 0 +depth (Node _ l r) = 1 + max (depth l) (depth r) +``` + +--- + +## Immutable Records + +```haskell +data User = User + { userId :: UserId + , userEmail :: String + , userName :: String + } deriving (Show, Eq) + +-- Record update syntax — creates a new value, original unchanged +updateEmail :: User -> String -> User +updateEmail user newEmail = user { userEmail = newEmail } + +-- Lens-based deep updates (with the `lens` library) +import Control.Lens + +data Profile = Profile { _user :: User, _address :: Address } +makeLenses ''Profile -- generates user and address lenses + +data Address = Address { _city :: String } +makeLenses ''Address + +-- Update nested field without manual spreading +updateCity :: Profile -> String -> Profile +updateCity profile city = profile & user . address . city .~ city +-- original profile is unchanged; a new Profile is returned +``` + +--- + +## Higher-Order Functions + +```haskell +-- map :: (a -> b) -> [a] -> [b] +doubled :: [Int] +doubled = map (* 2) [1, 2, 3] -- [2, 4, 6] + +-- filter :: (a -> Bool) -> [a] -> [a] +evens :: [Int] +evens = filter even [1..10] -- [2, 4, 6, 8, 10] + +-- foldl / foldr :: (b -> a -> b) -> b -> [a] -> b +total :: Int +total = foldl (+) 0 [1, 2, 3, 4, 5] -- 15 + +-- concatMap (equivalent to flatMap) +allWords :: [String] +allWords = concatMap words ["hello world", "foo bar"] +-- ["hello", "world", "foo", "bar"] + +-- zipWith :: (a -> b -> c) -> [a] -> [b] -> [c] +combined :: [(String, Int)] +combined = zipWith (,) ["Alice", "Bob"] [90, 85] + +-- Custom HOF — takes a function as argument +applyTwice :: (a -> a) -> a -> a +applyTwice f x = f (f x) +applyTwice (+ 3) 10 -- 16 +``` + +--- + +## Typeclasses (Functor, Applicative, Monad) + +```haskell +-- Functor: anything you can map over +-- fmap :: Functor f => (a -> b) -> f a -> f b +fmap (* 2) (Just 5) -- Just 10 +fmap (* 2) Nothing -- Nothing +fmap (* 2) [1, 2, 3] -- [2, 4, 6] +fmap (* 2) (Right 5) -- Right 10 + +-- Applicative: apply a wrapped function to a wrapped value +-- (<*>) :: Applicative f => f (a -> b) -> f a -> f b +Just (* 2) <*> Just 5 -- Just 10 +(+) <$> Just 3 <*> Just 5 -- Just 8 (parallel: both must be Just) + +-- Monad: sequencing dependent computations +-- (>>=) :: Monad m => m a -> (a -> m b) -> m b +Just 5 >>= \x -> Just (x * 2) -- Just 10 +Nothing >>= \x -> Just (x * 2) -- Nothing (short-circuits) + +-- do-notation desugars to >>= and >> +processUser :: UserId -> IO String +processUser uid = do + maybeUser <- findUserIO uid -- IO (Maybe User) + case maybeUser of + Nothing -> return "Not found" + Just user -> return (userName user) +``` + +--- + +## IO Boundary + +```haskell +-- In Haskell, IO in the type signature is the explicit marker for side effects +-- Functions without IO are pure — enforced by the compiler + +-- Pure — no IO +calculateTotal :: [Item] -> Double +calculateTotal = sum . map itemPrice + +-- Impure — IO in the type +loadAndProcess :: FilePath -> IO Double +loadAndProcess path = do + contents <- readFile path -- IO — reads file + let items = parseItems contents -- pure + return (calculateTotal items) -- pure, lifted into IO + +-- The shell is a thin layer; the core is pure +main :: IO () +main = do + total <- loadAndProcess "orders.csv" + putStrLn ("Total: " ++ show total) +``` diff --git a/skills/fp-best-practices/references/higher-order-functions.md b/skills/fp-best-practices/references/higher-order-functions.md new file mode 100644 index 0000000..dd518f9 --- /dev/null +++ b/skills/fp-best-practices/references/higher-order-functions.md @@ -0,0 +1,228 @@ +# Higher-Order Functions + +Use this reference when replacing imperative loops with functional transforms, working with functors and monads, or choosing between map, filter, reduce, and flatMap. + +## What Is a Higher-Order Function? + +A higher-order function (HOF) either: +- takes one or more functions as arguments, or +- returns a function as its result (currying and partial application) + +HOFs are the primary tool for abstraction in FP — they abstract over *behavior* rather than over *data*. + +## The Core Trio: map, filter, reduce + +### map — Transform Each Element + +`map` applies a function to each element of a structure and returns a new structure of the same shape. + +```javascript +// Imperative +const doubled = []; +for (const n of [1, 2, 3]) doubled.push(n * 2); + +// Functional +const doubled = [1, 2, 3].map(n => n * 2); // [2, 4, 6] +``` + +`map` preserves structure. The output has the same number of elements as the input. + +```typescript +// map on Option — only applies if Some +import { option as O } from "fp-ts"; +const maybeDouble = O.map((n: number) => n * 2); +maybeDouble(O.some(5)); // Some(10) +maybeDouble(O.none); // None +``` + +### filter — Select Elements by Predicate + +`filter` returns a new collection containing only elements that satisfy a predicate. + +```javascript +const activeUsers = users.filter(u => u.active); +const highValue = orders.filter(o => o.total > 1000); +``` + +### reduce / fold — Collapse to a Single Value + +`reduce` collapses a collection into a single value by applying an accumulator function: + +```javascript +// Sum +const total = [10, 20, 30].reduce((sum, n) => sum + n, 0); // 60 + +// Build an object from an array +const byId = users.reduce((acc, user) => ({ ...acc, [user.id]: user }), {}); + +// Reimplement map with reduce +const myMap = (arr, fn) => arr.reduce((acc, x) => [...acc, fn(x)], []); + +// Reimplement filter with reduce +const myFilter = (arr, pred) => + arr.reduce((acc, x) => (pred(x) ? [...acc, x] : acc), []); +``` + +`reduce` is the most general of the three. Every transformation on a sequence can be expressed as a fold. Use `map` and `filter` when they express intent more clearly. + +## flatMap / chain — Sequences That Produce Wrapped Values + +`flatMap` = `map` + `flatten`. It sequences computations where each step returns a wrapped value. + +```javascript +// map would give: [[1, 2], [3, 4], [5, 6]] +[[1, 2], [3, 4], [5, 6]].map(x => x); + +// flatMap flattens one level: [1, 2, 3, 4, 5, 6] +[[1, 2], [3, 4], [5, 6]].flatMap(x => x); + +// Practical use: expand each element into multiple elements +const words = ["hello world", "foo bar"].flatMap(s => s.split(" ")); +// ["hello", "world", "foo", "bar"] +``` + +In monadic terms, `flatMap`/`chain` sequences `Option`, `Either`, `Result`, or `Promise` computations: + +```typescript +// Short-circuits on the first None +const result = pipe( + findUser(id), // Option + O.chain(user => findProfile(user.id)), // Option + O.chain(profile => findAvatar(profile.avatarId)) // Option +); +``` + +## zip and zipWith — Combine Two Collections + +`zip` combines two arrays into pairs: + +```javascript +const names = ["Alice", "Bob"]; +const scores = [90, 85]; +const combined = names.map((name, i) => [name, scores[i]]); +// [["Alice", 90], ["Bob", 85]] + +// Ramda +R.zip(names, scores); // [["Alice", 90], ["Bob", 85]] +R.zipWith((name, score) => ({ name, score }), names, scores); +// [{ name: "Alice", score: 90 }, { name: "Bob", score: 85 }] +``` + +## groupBy — Partition by Key + +```javascript +// Native (ES2024) +const byStatus = Map.groupBy(orders, o => o.status); + +// Functional implementation +const groupBy = fn => arr => + arr.reduce((acc, item) => { + const key = fn(item); + return { ...acc, [key]: [...(acc[key] ?? []), item] }; + }, {}); + +const byStatus = groupBy(o => o.status)(orders); +``` + +## Transducers — Composable Transforms Without Intermediate Collections + +Standard `map` + `filter` chains create intermediate arrays. Transducers compose the transforms before applying them, processing each element once: + +```javascript +// Creates two intermediate arrays +const result = [1, 2, 3, 4, 5] + .filter(n => n % 2 === 0) // [2, 4] + .map(n => n * 10); // [20, 40] + +// Transducer — one pass, no intermediates (Ramda) +import * as R from "ramda"; +const xform = R.compose( + R.filter(n => n % 2 === 0), + R.map(n => n * 10) +); +R.transduce(xform, R.flip(R.append), [], [1, 2, 3, 4, 5]); // [20, 40] +``` + +Transducers matter for large collections or when the data source is a stream. For normal array sizes, the intermediate arrays are negligible. + +## Functor — Anything You Can Map Over + +A functor is any container that implements `map` in a lawful way: + +- `map(id)` = identity: `[1, 2].map(x => x)` → `[1, 2]` +- `map(f).map(g)` = `map(compose(g, f))`: mapping twice = mapping once with the composed function + +Arrays, `Option`/`Maybe`, `Either`/`Result`, `Promise`, and `Stream` are all functors. + +```typescript +// All functors respond to map +[1, 2, 3].map(double); // Array functor +option.some(5).map(double); // Option functor +Promise.resolve(5).then(double); // Promise functor (then ≈ map) +``` + +## Monad — Sequences That Can Short-Circuit + +A monad is a functor that also has: +- `of`/`return` — wrap a plain value +- `chain`/`flatMap`/`bind` — sequence dependent computations + +```typescript +// Promise is a monad +const result = Promise.resolve(userId) + .then(findUser) // string → Promise + .then(fetchProfile) // User → Promise + .then(renderProfile); // Profile → Promise + +// Option is a monad (short-circuits on None) +const result = pipe( + O.some(userId), + O.chain(findUser), // short-circuits if user not found + O.chain(fetchProfile), // short-circuits if profile not found + O.map(renderProfile) +); +``` + +The monad laws ensure sequential composition is associative and has a neutral element — which is what makes `then` and `chain` predictable to use. + +## Applicative — Independent Parallel Computations + +Applicatives apply a wrapped function to a wrapped value. Useful when computations are independent (not sequential): + +```typescript +// Sequential (monad): second depends on first +pipe( + findUser(id), + O.chain(user => findOrders(user.id)) +); + +// Parallel (applicative): both independent +import { applicative as A } from "fp-ts/Option"; +// Applies `buildSummary` to user and orders independently +A.ap(userOpt)(ordersOpt.map(orders => user => buildSummary(user, orders))); +``` + +In practice, use monads for sequential dependent operations and `Promise.all` / `Effect.all` for parallel independent ones. + +## Practical Heuristics + +| Situation | Function | +|---|---| +| Transform each element | `map` | +| Keep only some elements | `filter` | +| Combine elements into one value | `reduce` / `fold` | +| Each step may produce multiple results | `flatMap` | +| Two collections, pair elements | `zip` / `zipWith` | +| Group elements by a key | `groupBy` | +| Large collection, no intermediate arrays | transducers | +| Lift a function into a context | functor `map` | +| Chain dependent wrapped computations | monad `chain` | +| Combine independent wrapped computations | applicative `ap` | + +## Warning Signs + +- Using `reduce` where `map` or `filter` would express intent more clearly. +- A `map` callback with side effects — extract the effect to the boundary. +- A `for` loop that builds up a result array — usually replaceable with `map`/`filter`/`reduce`. +- Nested `flatMap` calls that form a staircase — consider `do`-notation (Haskell) or sequential `pipe` with `chain`. +- Chaining `map` on `null`/`undefined` — replace with `Option` first. diff --git a/skills/fp-best-practices/references/javascript-examples.md b/skills/fp-best-practices/references/javascript-examples.md new file mode 100644 index 0000000..90203af --- /dev/null +++ b/skills/fp-best-practices/references/javascript-examples.md @@ -0,0 +1,308 @@ +# JavaScript — Functional Programming Examples + +Concepts covered: pure functions, composition/pipe, currying, Maybe/Option, Either/Result, immutable updates, higher-order functions, sum types, side effect boundary. + +Libraries: native ES2022+, [Ramda](https://ramdajs.com), [fp-ts](https://gcanti.github.io/fp-ts/). + +--- + +## Pure Functions and Referential Transparency + +```javascript +// Impure — side effects, depends on external state +let discount = 0.1; +function applyDiscount(price) { + return price * (1 - discount); // depends on external variable +} + +// Pure — same input always gives same output +const applyDiscount = (rate, price) => price * (1 - rate); + +// Pure — immutable transform, no side effects +const addItem = (cart, item) => ({ ...cart, items: [...cart.items, item] }); +``` + +--- + +## Function Composition and Pipe + +```javascript +// Manual pipe (left-to-right) +const pipe = (...fns) => x => fns.reduce((v, f) => f(v), x); + +// Manual compose (right-to-left) +const compose = (...fns) => x => fns.reduceRight((v, f) => f(v), x); + +// Usage +const normalizeEmail = email => email.toLowerCase().trim(); +const validateEmail = email => email.includes("@") ? email : null; +const formatForDisplay = email => `<${email}>`; + +const processEmail = pipe(normalizeEmail, validateEmail, formatForDisplay); + +// With Ramda +const R = require("ramda"); +const processOrder = R.pipe( + R.filter(item => item.active), + R.map(item => ({ ...item, total: item.price * item.qty })), + R.sortBy(R.prop("total")) +); +``` + +--- + +## Currying and Partial Application + +```javascript +// Manual currying +const multiply = a => b => a * b; +const double = multiply(2); +const triple = multiply(3); + +double(5); // 10 +triple(5); // 15 + +// With Ramda auto-curry +const R = require("ramda"); + +const applyDiscount = R.curry((rate, price) => price * (1 - rate)); +const applyTenPercent = applyDiscount(0.1); +const applyTwentyPercent = applyDiscount(0.2); + +[100, 200, 300].map(applyTenPercent); // [90, 180, 270] + +// Partial application with bind +const greet = (greeting, name) => `${greeting}, ${name}!`; +const sayHello = greet.bind(null, "Hello"); +sayHello("Alice"); // "Hello, Alice!" +``` + +--- + +## Maybe / Option — Safe Navigation (no library) + +```javascript +// Simple Maybe implementation +const None = Object.freeze({ _tag: "None" }); +const Some = value => Object.freeze({ _tag: "Some", value }); + +const isSome = m => m._tag === "Some"; +const isNone = m => m._tag === "None"; + +const map = fn => maybe => + isSome(maybe) ? Some(fn(maybe.value)) : maybe; + +const chain = fn => maybe => + isSome(maybe) ? fn(maybe.value) : maybe; + +const getOrElse = fallback => maybe => + isSome(maybe) ? maybe.value : fallback; + +// Usage — safe property access +const getCity = user => + pipe( + () => user.address ? Some(user.address) : None, + chain(addr => addr.city ? Some(addr.city) : None), + getOrElse("Unknown city") + )(); + +// With Ramda +const safeProp = R.curry((key, obj) => + obj && obj[key] !== undefined ? Some(obj[key]) : None +); + +const getCity = R.pipe( + safeProp("address"), + chain(safeProp("city")), + getOrElse("Unknown") +); +``` + +--- + +## Either / Result — Explicit Failure (no library) + +```javascript +const Left = error => Object.freeze({ _tag: "Left", error }); +const Right = value => Object.freeze({ _tag: "Right", value }); + +const isRight = e => e._tag === "Right"; + +const mapRight = fn => either => + isRight(either) ? Right(fn(either.value)) : either; + +const chainRight = fn => either => + isRight(either) ? fn(either.value) : either; + +const match = (onLeft, onRight) => either => + isRight(either) ? onRight(either.value) : onLeft(either.error); + +// Validation functions +const validateEmail = email => + typeof email === "string" && email.includes("@") + ? Right(email.toLowerCase().trim()) + : Left("Invalid email address"); + +const validateAge = age => + Number.isInteger(age) && age >= 0 && age <= 150 + ? Right(age) + : Left("Age must be between 0 and 150"); + +// Chain validations +const parseUser = ({ email, age }) => { + const emailResult = validateEmail(email); + if (!isRight(emailResult)) return emailResult; + + const ageResult = validateAge(age); + if (!isRight(ageResult)) return ageResult; + + return Right({ email: emailResult.value, age: ageResult.value }); +}; + +// Usage +const result = parseUser({ email: "alice@example.com", age: 30 }); +match( + error => console.error("Error:", error), + user => console.log("User:", user) +)(result); +``` + +--- + +## Immutable Data Transforms + +```javascript +// Spread for shallow immutable updates +const updateEmail = (user, email) => ({ ...user, email }); +const addItem = (cart, item) => ({ ...cart, items: [...cart.items, item] }); +const removeItem = (cart, itemId) => ({ + ...cart, + items: cart.items.filter(i => i.id !== itemId), +}); + +// Nested updates (manual) +const updateCity = (user, city) => ({ + ...user, + address: { ...user.address, city }, +}); + +// With Ramda lenses for deep updates +const R = require("ramda"); +const cityLens = R.lensPath(["address", "city"]); +const updateCity = R.set(cityLens, "Madrid"); +const updatedUser = updateCity(user); // user unchanged, new object returned +``` + +--- + +## Higher-Order Functions + +```javascript +const orders = [ + { id: 1, status: "active", items: [{ price: 10 }, { price: 20 }] }, + { id: 2, status: "cancelled", items: [{ price: 15 }] }, + { id: 3, status: "active", items: [{ price: 5 }, { price: 30 }] }, +]; + +// map — transform +const withTotals = orders.map(o => ({ + ...o, + total: o.items.reduce((sum, i) => sum + i.price, 0), +})); + +// filter — select +const activeOrders = orders.filter(o => o.status === "active"); + +// reduce — aggregate +const grandTotal = orders + .filter(o => o.status === "active") + .map(o => o.items.reduce((s, i) => s + i.price, 0)) + .reduce((sum, t) => sum + t, 0); + +// flatMap — expand +const allItems = orders.flatMap(o => o.items); + +// groupBy (ES2024) +const byStatus = Map.groupBy(orders, o => o.status); + +// Transducer-style with Ramda (one pass, no intermediates) +const R = require("ramda"); +const summarizeActive = R.pipe( + R.filter(o => o.status === "active"), + R.map(o => o.items.reduce((s, i) => s + i.price, 0)), + R.sum +); +``` + +--- + +## Algebraic Data Types — Sum Types + +```javascript +// Tagged union (no library) +const OrderStatus = { + pending: () => ({ _tag: "pending" }), + shipped: (trackingCode) => ({ _tag: "shipped", trackingCode }), + delivered: (deliveredAt) => ({ _tag: "delivered", deliveredAt }), + cancelled: (reason) => ({ _tag: "cancelled", reason }), +}; + +const describeStatus = status => { + switch (status._tag) { + case "pending": return "Awaiting shipment"; + case "shipped": return `Shipped (${status.trackingCode})`; + case "delivered": return `Delivered on ${status.deliveredAt.toDateString()}`; + case "cancelled": return `Cancelled: ${status.reason}`; + default: throw new Error(`Unknown status: ${status._tag}`); + } +}; + +const order = { id: "123", status: OrderStatus.shipped("TRACK-456") }; +describeStatus(order.status); // "Shipped (TRACK-456)" +``` + +--- + +## Side Effect Boundary (Functional Core / Imperative Shell) + +```javascript +// Pure core — all logic, no I/O +function processRegistration(existingEmails, input) { + const email = input.email?.toLowerCase().trim(); + if (!email?.includes("@")) return Left("Invalid email"); + if (existingEmails.has(email)) return Left("Email already registered"); + return Right({ id: crypto.randomUUID(), email, createdAt: new Date() }); +} + +// Impure shell — only coordinates I/O +async function registerUser(input) { + const existingEmails = await db.users.allEmails(); // I/O + const result = processRegistration(existingEmails, input); // pure + if (isRight(result)) { + await db.users.save(result.value); // I/O + } + return result; +} +``` + +--- + +## Dependency Injection via Function Parameters + +```javascript +// Hard-coded I/O — untestable +async function getActiveUsers() { + return (await db.users.findAll()).filter(u => u.active); +} + +// Injected — testable with any data source +const getActiveUsers = findAll => async () => + (await findAll()).filter(u => u.active); + +// Production +const prodGetActiveUsers = getActiveUsers(() => db.users.findAll()); + +// Test +const fakeUsers = [{ id: "1", active: true }, { id: "2", active: false }]; +const testGetActiveUsers = getActiveUsers(() => Promise.resolve(fakeUsers)); +``` diff --git a/skills/fp-best-practices/references/language-examples.md b/skills/fp-best-practices/references/language-examples.md new file mode 100644 index 0000000..7efe809 --- /dev/null +++ b/skills/fp-best-practices/references/language-examples.md @@ -0,0 +1,62 @@ +# Language Examples + +Use this file as an index to the language-specific functional programming references. + +## Covered Languages + +- `references/javascript-examples.md` — native ES2022+, Ramda, fp-ts +- `references/typescript-examples.md` — fp-ts, Effect-TS, branded types +- `references/python-examples.md` — functools, itertools, returns, pattern matching (3.10+) +- `references/go-examples.md` — first-class functions, generics (1.18+), functional options, `(value, error)` +- `references/rust-examples.md` — Iterator trait, Option/Result, enums, newtype pattern, `?` operator +- `references/haskell-examples.md` — pure FP reference, typeclasses, IO monad +- `references/elm-examples.md` — frontend FP, The Elm Architecture, decoders +- `references/clojure-examples.md` — LISP FP, threading macros, persistent data structures +- `references/fsharp-examples.md` — .NET functional-first, computation expressions +- `references/elixir-examples.md` — BEAM concurrency, OTP, pipe operator, `with` + +## Shared Concept Set + +Each language file covers the same concepts: + +- Pure functions and referential transparency +- Function composition / pipe operator +- Currying and partial application +- Maybe / Option — explicit absence +- Either / Result — explicit failure +- Sum types / discriminated unions / tagged variants +- Immutable data transforms +- Higher-order functions (map, filter, reduce, flatMap) +- Side effect boundary (functional core / imperative shell) +- Dependency injection via function parameters + +## How to Use This Reference + +- Read the language file that matches the user's codebase first. +- If the user works across multiple languages, compare the same concept across files. +- Prefer concept-level consistency over syntax-level imitation. +- When adding a new concept, add it to every language file so the set stays aligned. +- Use `core-principles.md`, `function-composition.md`, `algebraic-data-types.md`, `managing-side-effects.md`, or `higher-order-functions.md` when an example raises a deeper FP design question. + +## Suggested Reading Order + +1. Start with the language the user is actively using. +2. Review pure functions, composition/pipe, and immutability first. +3. Compare Maybe/Option and Either/Result across one dynamic and one static language. +4. Compare the side effect boundary pattern across languages to see the common structure. +5. Return to `core-principles.md` when a design question goes beyond syntax. + +## Quick Reference Table + +| Language | Pipe | Compose | Option / Maybe | Either / Result | Ecosystem | +|---|---|---|---|---|---| +| JavaScript | `pipe` (Ramda, fp-ts) | `compose` (Ramda) | `fp-ts/Option` | `fp-ts/Either` | Ramda, Lodash/FP | +| TypeScript | `pipe` (fp-ts) | `flow` (fp-ts) | `fp-ts/Option` | `fp-ts/Either` | fp-ts, Effect-TS | +| Haskell | `&` | `.` | `Maybe` (built-in) | `Either` (built-in) | base, lens, mtl | +| Elm | `\|>` | `>>` / `<<` | `Maybe` (built-in) | `Result` (built-in) | elm/core | +| Clojure | `->` / `->>` | `comp` | `nil` + `some->` | maps + cond | clojure.core, spec | +| F# | `\|>` | `>>` | `Option` (built-in) | `Result` (built-in) | FSharpPlus, Giraffe | +| Python | `pipe` (toolz) | `compose` (toolz) | `Optional[T]` / `Maybe` (returns) | `Result` (returns) | functools, itertools, returns | +| Go | closures / `Pipeline` fn | closures | `(value, bool)` / `sql.NullX` | `(value, error)` | stdlib, slices (1.21+) | +| Rust | `.` method chaining | closures | `Option` (built-in) | `Result` (built-in) | std::iter, itertools | +| Elixir | `\|>` | manual `fn` | `{:ok, _}` / `nil` | `{:ok, _}` / `{:error, _}` | Enum, Stream, OTP | diff --git a/skills/fp-best-practices/references/managing-side-effects.md b/skills/fp-best-practices/references/managing-side-effects.md new file mode 100644 index 0000000..d8d410a --- /dev/null +++ b/skills/fp-best-practices/references/managing-side-effects.md @@ -0,0 +1,230 @@ +# Managing Side Effects + +Use this reference when deciding how to structure I/O, inject dependencies into pure functions, or design the boundary between pure logic and the outside world. + +## Side Effects Are Not the Enemy + +Every useful program has side effects: reading a database, writing to a file, sending HTTP requests, logging, generating random numbers. The functional approach is not to eliminate effects but to: + +1. Make them visible in the type system +2. Isolate them to the boundary +3. Keep the core logic pure so it can be tested without them + +## The Functional Core / Imperative Shell + +(Gary Bernhardt's pattern — also known as "Ports and Adapters for FP") + +``` + ┌──────────────────────────────┐ + │ Imperative Shell (impure) │ + │ - reads from database │ + │ - sends HTTP requests │ + │ - writes to file system │ + │ - generates random values │ +┌───────────────┼──────────────────────────────┤ +│ Pure Core │ calls into the core with │ +│ │ plain values │ +│ - all domain ◄──────────────────────────────┤ +│ logic │ receives plain values back │ +│ - validation ├──────────────────────────────┘ +│ - transforms │ +└───────────────┘ +``` + +The shell does the minimum: fetch data → call core with plain values → persist result. + +```javascript +// Pure core — no I/O, fully testable +function processRegistration(existingEmails, rawInput) { + if (existingEmails.has(rawInput.email)) { + return { ok: false, error: "Email already registered" }; + } + const user = { id: generateId(), ...normalize(rawInput), createdAt: new Date() }; + return { ok: true, user }; +} + +// Impure shell — orchestrates I/O +async function registerUser(rawInput) { + const existingEmails = await db.users.allEmails(); // I/O + const result = processRegistration(existingEmails, rawInput); // pure + if (result.ok) await db.users.save(result.user); // I/O + return result; +} +``` + +## Dependency Injection via Function Parameters + +In FP, dependency injection is just passing functions as arguments. No DI container needed. + +```javascript +// Hard-coded dependency — not testable +async function getActiveUsers() { + const users = await db.users.findAll(); // coupled to the database + return users.filter(u => u.active); +} + +// Injected dependency — testable with any data source +async function getActiveUsers(findAllUsers) { + const users = await findAllUsers(); + return users.filter(u => u.active); +} + +// In tests +const fakeUsers = [{ id: "1", active: true }, { id: "2", active: false }]; +await getActiveUsers(() => Promise.resolve(fakeUsers)); + +// In production +await getActiveUsers(() => db.users.findAll()); +``` + +For multiple dependencies, group them into a "context" or "environment" object: + +```typescript +type AppEnv = { + db: Database; + logger: Logger; + clock: () => Date; + idGenerator: () => string; +}; + +const createUser = (env: AppEnv) => async (input: CreateUserInput): Promise => { + const id = env.idGenerator(); + const createdAt = env.clock(); + const user = { id, ...input, createdAt }; + await env.db.users.save(user); + env.logger.info("User created", { id }); + return user; +}; +``` + +## Reader Monad Pattern + +The Reader monad threads a shared environment through a computation without passing it explicitly at every step: + +```typescript +// fp-ts Reader example +import { Reader } from "fp-ts/Reader"; +import { pipe } from "fp-ts/function"; +import * as R from "fp-ts/Reader"; + +type Config = { dbUrl: string; logLevel: string }; + +const readDbUrl: Reader = (config) => config.dbUrl; +const readLogLevel: Reader = (config) => config.logLevel; + +const connectionString: Reader = pipe( + readDbUrl, + R.map(url => `Connected to ${url}`) +); + +// Run with a concrete config +connectionString({ dbUrl: "postgres://localhost/mydb", logLevel: "info" }); +``` + +In most practical codebases, plain dependency injection via parameters achieves the same effect with less ceremony. + +## IO Monad (Haskell) + +In Haskell, all I/O lives in the `IO` monad. The type system prevents side effects from escaping the IO context: + +```haskell +-- Pure — no IO +add :: Int -> Int -> Int +add x y = x + y + +-- Impure — IO in the type +getUser :: UserId -> IO (Maybe User) +getUser uid = do + row <- queryDB "SELECT * FROM users WHERE id = ?" [uid] + return (parseUser row) + +-- Composition in IO +processUser :: UserId -> IO String +processUser uid = do + maybeUser <- getUser uid + return $ case maybeUser of + Nothing -> "Not found" + Just user -> greet user +``` + +The IO monad makes impurity a type-level guarantee — a function without `IO` in its return type cannot perform side effects. + +## Effect Systems (TypeScript: Effect-TS) + +Effect-TS brings a typed effect system to TypeScript, making errors and dependencies visible in types: + +```typescript +import { Effect, pipe } from "effect"; + +// Effect +const getUser = (id: string): Effect.Effect => + Effect.tryPromise({ + try: () => db.users.findById(id), + catch: () => new UserNotFound(id), + }); + +const processUser = (id: string) => + pipe( + getUser(id), + Effect.map(user => ({ ...user, lastSeen: new Date() })), + Effect.flatMap(saveUser), + ); +``` + +The `Requirements` type parameter tracks which services the effect needs — the compiler prevents running an effect without its dependencies. + +## Separating Commands from Queries (CQRS for Functions) + +Apply Command-Query Separation at the function level: + +- **Query function**: pure, returns a value, no side effects +- **Command function**: produces side effects, returns unit (`void`, `()`, `IO ()`) + +```javascript +// Query — pure, no side effects +function calculateTotal(items) { + return items.reduce((sum, item) => sum + item.price * item.quantity, 0); +} + +// Command — side effect, returns nothing meaningful +async function saveOrder(order) { + await db.orders.insert(order); + await eventBus.publish("OrderCreated", order); +} +``` + +Mixing a meaningful return value with side effects (returning the saved entity after mutating the database) creates coupling. Prefer returning the transformed value from the pure function and discarding it in the command. + +## Handling Async Effects + +Async operations are side effects. Manage them with: + +```javascript +// Promise chain — sequential async pipeline +const orderSummary = (orderId) => + fetchOrder(orderId) + .then(validateOrder) + .then(applyDiscounts) + .then(formatSummary) + .catch(handleOrderError); + +// async/await — cleaner for sequential effects with branching +async function processOrder(orderId) { + const order = await fetchOrder(orderId); + const validated = validateOrder(order); // pure + const discounted = applyDiscounts(validated); // pure + await saveOrder(discounted); // effect + return formatSummary(discounted); // pure +} +``` + +Keep pure transformations (`validateOrder`, `applyDiscounts`, `formatSummary`) outside the async chain when possible — they do not need to be async. + +## Warning Signs + +- A "pure" function that reads from a global variable or module-level state. +- A function that catches an exception internally and returns null — use `Either`/`Result`. +- Logging or event publishing buried inside domain logic. +- Database calls inside functions described as business rules. +- A test that passes with real infrastructure but fails with mocks (hidden coupling). +- Deeply nested async chains where pure transformations are mixed with I/O. diff --git a/skills/fp-best-practices/references/naming-and-abstractions.md b/skills/fp-best-practices/references/naming-and-abstractions.md new file mode 100644 index 0000000..94ec457 --- /dev/null +++ b/skills/fp-best-practices/references/naming-and-abstractions.md @@ -0,0 +1,148 @@ +# Naming and Abstractions + +Use this reference when naming quality, over-generalization, or weak abstractions are the main design problem. + +## Name Functions by the Transformation They Perform + +A function name should describe what goes in and what comes out, not the mechanism. + +```javascript +// Weak — describes the mechanism +function doStringProcessing(str) { ... } +function handleUser(user) { ... } +function processData(data) { ... } + +// Strong — describes the transformation +function normalizeEmail(email) { ... } +function activateUser(user) { ... } +function aggregateSalesByRegion(sales) { ... } +``` + +If a function's name cannot describe its input-output contract, the function probably mixes several transformations. + +## Name Types by What They Represent + +In typed FP (TypeScript, Haskell, Elm, F#, Scala), type names define the abstraction the reader will model. + +```typescript +// Weak — too generic +type Data = Record; +type Result = { value: any; error?: string }; + +// Strong — describes the domain concept +type OrderId = string & { readonly _brand: "OrderId" }; +type ValidationError = { field: string; message: string }; +type ParseResult = { ok: true; value: T } | { ok: false; error: string }; +``` + +Branded/nominal types catch category errors at compile time. A function expecting `UserId` should not silently accept an `OrderId`. + +## Avoid Generic Names + +Generic names hide intent and merge distinct concepts: + +| Avoid | Prefer | +|---|---| +| `process` | `validate`, `transform`, `aggregate`, `normalize` | +| `handle` | `onPaymentReceived`, `onUserCreated` | +| `data` | `order`, `invoice`, `userProfile` | +| `result` | `parsedDate`, `discountedTotal`, `activeUsers` | +| `helper` / `utils` | a module name that describes what it helps with | +| `Manager` | the concept it manages | + +A name like `processData` forces every reader to read the function body to understand what it does. + +## If a Name Is Hard to Find, Recheck the Abstraction + +The difficulty of naming is data about the design: + +- Cannot name it without "and" → it does two things (split it) +- Falling back to `process`, `handle`, `do` → the concept is unclear (rethink the boundary) +- The name conflicts with another in the same module → the module has mixed responsibilities (separate them) + +## DRY Means Shared Logic, Not Identical Syntax + +Extract a function when multiple callsites express the same transformation for the same reason. +Do not extract when the code only looks similar but represents different domain rules. + +```javascript +// These look similar but represent different business rules — do not merge +const applyEmployeeDiscount = (price) => price * 0.85; +const applyLoyaltyDiscount = (price) => price * 0.85; + +// These are the same rule — extract +const applyStandardDiscount = (price) => price * 0.85; +``` + +If one copy changes, should the others always change too? If not, they are not the same knowledge. + +## Avoid Clever Point-Free When It Obscures Meaning + +Point-free (tacit) style removes the data argument from function definitions. It is useful when the pipeline reads naturally — it becomes a smell when the reader has to mentally reconstruct what data flows through. + +```javascript +// Clear point-free — each step is named and reads like a pipeline +const summarizeOrder = pipe( + filterActiveItems, + calculateSubtotal, + applyTax, + formatCurrency +); + +// Obscure point-free — the combination of partial application is harder to read +const process = compose( + map(compose(prop("value"), filter(Boolean))), + reduce(mergeWith(add), {}) +); +``` + +Rule: use point-free when it makes the pipeline more readable, not just shorter. + +## Pipeline Vocabulary Should Match the Domain + +Name pipeline steps after domain operations, not after the functions used to implement them: + +```javascript +// Technical vocabulary dominates +pipe( + xs => xs.filter(x => x.status === "active"), + xs => xs.map(x => ({ ...x, score: x.value * 1.1 })), + xs => xs.reduce((acc, x) => acc + x.score, 0) +)(orders); + +// Domain vocabulary is visible +pipe( + filterActiveOrders, + applyLoyaltyBonus, + sumScores +)(orders); +``` + +The second version reads as a specification. A domain expert can follow it. + +## Magic Values Are Unnamed Concepts + +An inline literal is a concept without a name: + +```javascript +// What does 0.21 mean? +const tax = subtotal * 0.21; + +// Named — the business rule is explicit +const VAT_RATE = 0.21; +const tax = subtotal * VAT_RATE; +``` + +Apply the same rule to string patterns, thresholds, and format strings. + +## Avoid Overloaded Parameter Names + +In curried or higher-order functions, parameter names compound: outer function parameters shadow inner ones. Name each level so readers can track data flow: + +```javascript +// Confusing — `x` used at multiple levels with different meanings +const transform = x => y => x + y; + +// Clear — each level named for its role +const addBase = base => increment => base + increment; +``` diff --git a/skills/fp-best-practices/references/python-examples.md b/skills/fp-best-practices/references/python-examples.md new file mode 100644 index 0000000..a08bcc2 --- /dev/null +++ b/skills/fp-best-practices/references/python-examples.md @@ -0,0 +1,388 @@ +# Python — Functional Programming Examples + +Concepts covered: pure functions, composition/pipe, currying, Optional/None, Result pattern, immutable data, higher-order functions, comprehensions, generators, pattern matching. + +Libraries: `functools`, `itertools`, [`returns`](https://returns.readthedocs.io), [`toolz`](https://toolz.readthedocs.io). + +--- + +## Pure Functions and Referential Transparency + +```python +# Impure — mutates argument, depends on external state +discount_rate = 0.1 + +def apply_discount(price): + return price * (1 - discount_rate) # hidden dependency + +# Pure — all inputs explicit, no side effects +def apply_discount(rate: float, price: float) -> float: + return price * (1 - rate) + +# Pure — returns new dict, original unchanged +def add_item(cart: dict, item: dict) -> dict: + return {**cart, "items": [*cart["items"], item]} +``` + +--- + +## Function Composition and Pipe + +```python +from functools import reduce + +# Manual pipe (left-to-right) +def pipe(*fns): + return lambda x: reduce(lambda v, f: f(v), fns, x) + +normalize = lambda s: s.strip().lower() +validate = lambda s: s if "@" in s else None +wrap = lambda s: f"<{s}>" if s else None + +process_email = pipe(normalize, validate, wrap) +process_email(" Alice@Example.com ") # "" + +# With toolz +from toolz import pipe, compose, curry + +result = pipe( + orders, + lambda os: filter(lambda o: o["active"], os), + lambda os: map(lambda o: o["total"], os), + sum, +) + +# compose — right-to-left +normalize_email = compose(str.lower, str.strip) +normalize_email(" Alice@EXAMPLE.COM ") # "alice@example.com" +``` + +--- + +## Currying and Partial Application + +```python +from functools import partial + +# Manual currying +def multiply(a): + return lambda b: a * b + +double = multiply(2) +triple = multiply(3) + +list(map(double, [1, 2, 3])) # [2, 4, 6] + +# functools.partial — fix leftmost arguments +def apply_discount(rate, price): + return price * (1 - rate) + +apply_ten_percent = partial(apply_discount, 0.1) +apply_twenty = partial(apply_discount, 0.2) + +apply_ten_percent(200) # 180.0 +list(map(apply_ten_percent, [100, 200, 300])) # [90.0, 180.0, 270.0] + +# With toolz.curry — auto-curry any function +from toolz import curry + +@curry +def apply_discount(rate, price): + return price * (1 - rate) + +apply_ten_percent = apply_discount(0.1) +apply_ten_percent(200) # 180.0 +``` + +--- + +## Optional / None Handling + +```python +from typing import Optional + +# None as Optional — always declare Optional[T] in type hints +def find_user(user_id: str) -> Optional[dict]: + return db.users.get(user_id) # returns None if not found + +# Safe chaining — check at each step +def get_city(user: Optional[dict]) -> Optional[str]: + if user is None: + return None + address = user.get("address") + if address is None: + return None + return address.get("city") + +# Python 3.10+ — match on Optional +def display_city(user_id: str) -> str: + match get_city(find_user(user_id)): + case None: + return "Unknown city" + case city: + return city + +# With returns library — Option monad +from returns.maybe import Maybe, Nothing, Some + +def find_user(user_id: str) -> Maybe[dict]: + user = db.users.get(user_id) + return Some(user) if user else Nothing + +def get_city(user_id: str) -> Maybe[str]: + return ( + find_user(user_id) + .bind(lambda u: Maybe.from_optional(u.get("address"))) + .bind(lambda a: Maybe.from_optional(a.get("city"))) + ) + +get_city("123").value_or("Unknown city") +``` + +--- + +## Result Pattern — Explicit Failure + +```python +from dataclasses import dataclass +from typing import Generic, TypeVar, Union + +T = TypeVar("T") +E = TypeVar("E") + +@dataclass(frozen=True) +class Ok(Generic[T]): + value: T + +@dataclass(frozen=True) +class Err(Generic[E]): + error: E + +Result = Union[Ok[T], Err[E]] + +# Validation functions +def validate_email(email: str) -> Result: + if "@" not in email: + return Err("Invalid email") + return Ok(email.strip().lower()) + +def validate_age(age: int) -> Result: + if not (0 <= age <= 150): + return Err("Age must be between 0 and 150") + return Ok(age) + +def parse_user(email: str, age: int) -> Result: + email_result = validate_email(email) + if isinstance(email_result, Err): + return email_result + age_result = validate_age(age) + if isinstance(age_result, Err): + return age_result + return Ok({"email": email_result.value, "age": age_result.value}) + +# With returns library — Result monad + Railway-Oriented Programming +from returns.result import Result, Success, Failure + +def validate_email(email: str) -> Result[str, str]: + if "@" not in email: + return Failure("Invalid email") + return Success(email.strip().lower()) + +def validate_age(age: int) -> Result[int, str]: + if not (0 <= age <= 150): + return Failure("Age out of range") + return Success(age) +``` + +--- + +## Immutable Data + +```python +from dataclasses import dataclass, replace +from typing import FrozenSet, Tuple + +# frozen=True makes the dataclass immutable +@dataclass(frozen=True) +class User: + id: str + name: str + email: str + +# replace() — creates a new instance with updated fields +user = User(id="1", name="Alice", email="alice@example.com") +updated = replace(user, email="new@example.com") +# user is unchanged + +# Named tuple — immutable, lightweight +from typing import NamedTuple + +class Point(NamedTuple): + x: float + y: float + +p = Point(1.0, 2.0) +moved = Point(p.x + 1, p.y) # new Point + +# Immutable collections +items: Tuple[int, ...] = (1, 2, 3) +tags: FrozenSet[str] = frozenset({"python", "fp"}) + +# "Update" by creating new tuples +more_items = items + (4,) # (1, 2, 3, 4) — items unchanged +``` + +--- + +## Higher-Order Functions + +```python +from functools import reduce +from itertools import chain, groupby +from operator import attrgetter + +orders = [ + {"id": 1, "status": "active", "total": 150.0}, + {"id": 2, "status": "cancelled", "total": 80.0}, + {"id": 3, "status": "active", "total": 200.0}, +] + +# map — transform +totals = list(map(lambda o: o["total"], orders)) + +# filter — select +active = list(filter(lambda o: o["status"] == "active", orders)) + +# reduce — fold +grand_total = reduce(lambda acc, o: acc + o["total"], orders, 0.0) + +# Comprehensions — idiomatic Python alternative to map/filter +totals = [o["total"] for o in orders] +active = [o for o in orders if o["status"] == "active"] + +# Generator expressions — lazy (no intermediate list) +grand_total = sum(o["total"] for o in orders if o["status"] == "active") + +# itertools.chain — flatMap equivalent +all_items = list(chain.from_iterable(o["items"] for o in orders)) + +# groupby (requires pre-sorting) +sorted_orders = sorted(orders, key=lambda o: o["status"]) +by_status = {k: list(v) for k, v in groupby(sorted_orders, key=lambda o: o["status"])} + +# sorted with key — functional sort +top_orders = sorted(orders, key=lambda o: o["total"], reverse=True)[:3] +``` + +--- + +## Pattern Matching (Python 3.10+) + +```python +# Structural pattern matching — like algebraic data type dispatch + +def describe_status(status: dict) -> str: + match status: + case {"kind": "pending"}: + return "Awaiting shipment" + case {"kind": "shipped", "tracking_code": code}: + return f"Shipped — {code}" + case {"kind": "delivered", "delivered_at": date}: + return f"Delivered on {date}" + case {"kind": "cancelled", "reason": reason}: + return f"Cancelled: {reason}" + case _: + return "Unknown status" + +# Pattern matching on types (sum type simulation) +from dataclasses import dataclass + +@dataclass +class Pending: created_at: str +@dataclass +class Shipped: tracking_code: str +@dataclass +class Cancelled: reason: str + +def describe(status) -> str: + match status: + case Pending(): return "Awaiting shipment" + case Shipped(code): return f"Shipped — {code}" + case Cancelled(reason): return f"Cancelled: {reason}" +``` + +--- + +## Side Effect Boundary + +```python +from dataclasses import dataclass + +@dataclass(frozen=True) +class RegistrationInput: + email: str + name: str + +# Pure core — all logic, no I/O +def process_registration( + existing_emails: frozenset[str], + input: RegistrationInput, + user_id: str, +) -> Result: + email = input.email.strip().lower() + if "@" not in email: + return Err("Invalid email") + if email in existing_emails: + return Err("Email already registered") + return Ok({"id": user_id, "email": email, "name": input.name}) + +# Impure shell — thin I/O coordination +def register_user(input: RegistrationInput) -> Result: + existing = frozenset(db.users.all_emails()) # I/O + user_id = generate_uuid() # I/O (randomness) + result = process_registration(existing, input, user_id) # pure + if isinstance(result, Ok): + db.users.save(result.value) # I/O + return result + +# Dependency injection via function parameters +def register_user( + input: RegistrationInput, + fetch_emails=lambda: db.users.all_emails(), + save_user=db.users.save, + gen_id=generate_uuid, +) -> Result: + existing = frozenset(fetch_emails()) + result = process_registration(existing, input, gen_id()) + if isinstance(result, Ok): + save_user(result.value) + return result +``` + +--- + +## Generators and Lazy Evaluation + +```python +from itertools import islice, takewhile, count + +# Generator function — lazy sequence +def fibonacci(): + a, b = 0, 1 + while True: + yield a + a, b = b, a + b + +# Consume lazily — no infinite list in memory +first_10_fibs = list(islice(fibonacci(), 10)) +fibs_under_100 = list(takewhile(lambda n: n < 100, fibonacci())) + +# Generator expression — lazy pipeline +def process_large_file(path: str): + with open(path) as f: + lines = (line.strip() for line in f) + valid = (line for line in lines if line and not line.startswith("#")) + parsed = (parse_record(line) for line in valid) + return sum(r["amount"] for r in parsed if r["active"]) + # Only one line in memory at a time +``` diff --git a/skills/fp-best-practices/references/rust-examples.md b/skills/fp-best-practices/references/rust-examples.md new file mode 100644 index 0000000..c92f1e2 --- /dev/null +++ b/skills/fp-best-practices/references/rust-examples.md @@ -0,0 +1,353 @@ +# Rust — Functional Programming Examples + +Concepts covered: pure functions, iterators (map/filter/fold/flat_map), closures, `Option`, `Result`, pattern matching, algebraic data types (enums), immutability by default, `?` operator, function composition. + +Rust's ownership system makes pure functions the natural default — mutation is explicit and visible. The iterator trait provides a rich functional pipeline API. + +--- + +## Pure Functions and Immutability by Default + +```rust +// let bindings are immutable by default — mutation requires `mut` +let x = 5; // immutable +let mut y = 5; // explicit mutation + +// Pure function — no hidden state, no side effects +fn apply_discount(rate: f64, price: f64) -> f64 { + price * (1.0 - rate) +} + +// Structs implement value semantics with ownership +#[derive(Debug, Clone)] +struct Cart { + items: Vec, +} + +// Returns a new Cart — original is moved or cloned, never mutated +fn add_item(mut cart: Cart, item: Item) -> Cart { + cart.items.push(item); + cart // returns the modified owned value +} + +// If you need to keep the original, clone first +fn add_item_pure(cart: &Cart, item: Item) -> Cart { + let mut new_items = cart.items.clone(); + new_items.push(item); + Cart { items: new_items } +} +``` + +--- + +## Closures and First-Class Functions + +```rust +// Closures are anonymous functions that capture their environment +let double = |x: i32| x * 2; +let add_n = |n: i32| move |x: i32| x + n; // move captures n by value + +let add5 = add_n(5); +add5(10) // 15 + +// Function pointers for zero-cost abstractions +fn apply(f: fn(i32) -> i32, x: i32) -> i32 { + f(x) +} + +// Generic higher-order function +fn apply_twice i32>(f: F, x: i32) -> i32 { + f(f(x)) +} + +apply_twice(|x| x + 3, 10) // 16 +apply_twice(double, 5) // 20 +``` + +--- + +## Iterator — Functional Pipelines + +```rust +// The Iterator trait is Rust's primary FP tool +// Iterators are lazy — no intermediate allocations until .collect() + +let orders = vec![ + Order { id: 1, active: true, total: 150.0 }, + Order { id: 2, active: false, total: 80.0 }, + Order { id: 3, active: true, total: 200.0 }, +]; + +// map — transform +let totals: Vec = orders.iter().map(|o| o.total).collect(); + +// filter — select +let active: Vec<&Order> = orders.iter().filter(|o| o.active).collect(); + +// fold (= reduce) — aggregate +let grand_total: f64 = orders.iter() + .filter(|o| o.active) + .map(|o| o.total) + .fold(0.0, |acc, t| acc + t); + +// sum/product — convenience reducers +let total: f64 = orders.iter().map(|o| o.total).sum(); + +// flat_map — expand nested +let all_items: Vec<&Item> = orders.iter() + .flat_map(|o| o.items.iter()) + .collect(); + +// chain — concatenate iterators +let combined = first.iter().chain(second.iter()); + +// zip — pair two iterators +let pairs: Vec<_> = names.iter().zip(scores.iter()).collect(); + +// enumerate — add index +for (i, order) in orders.iter().enumerate() { + println!("{}: {:?}", i, order); +} + +// any / all — boolean aggregation +let has_active = orders.iter().any(|o| o.active); +let all_active = orders.iter().all(|o| o.active); + +// find — first matching +let first_active = orders.iter().find(|o| o.active); + +// take_while / skip_while +let until_cancelled: Vec<_> = orders.iter() + .take_while(|o| o.active) + .collect(); +``` + +--- + +## Option — Explicit Absence + +```rust +// Option = Some(T) | None +// The compiler forces you to handle both cases + +fn find_user(id: &str, db: &HashMap) -> Option<&User> { + db.get(id) +} + +// Pattern matching — exhaustive +match find_user("123", &db) { + None => println!("Not found"), + Some(user) => println!("Found: {}", user.name), +} + +// if let — match only the Some case +if let Some(user) = find_user("123", &db) { + println!("Found: {}", user.name); +} + +// map — transform the value inside Some +let email: Option = find_user("123", &db) + .map(|u| u.email.clone()); + +// and_then — chain Option computations (flatMap) +let city: Option<&str> = find_user("123", &db) + .and_then(|u| u.address.as_ref()) + .and_then(|a| a.city.as_deref()); + +// unwrap_or — provide a default +let city = get_city(&user).unwrap_or("Unknown"); + +// unwrap_or_else — lazy default (only evaluated if None) +let city = get_city(&user).unwrap_or_else(|| fetch_default_city()); + +// ? operator in functions returning Option +fn get_city(user: &User) -> Option<&str> { + let address = user.address.as_ref()?; // returns None if None + address.city.as_deref() +} +``` + +--- + +## Result — Explicit Failure + +```rust +use std::fmt; + +#[derive(Debug)] +enum ValidationError { + InvalidEmail(String), + InvalidAge(i32), +} + +impl fmt::Display for ValidationError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::InvalidEmail(e) => write!(f, "Invalid email: {e}"), + Self::InvalidAge(a) => write!(f, "Invalid age: {a}"), + } + } +} + +fn validate_email(email: &str) -> Result { + if email.contains('@') { + Ok(email.to_lowercase().trim().to_string()) + } else { + Err(ValidationError::InvalidEmail(email.to_string())) + } +} + +fn validate_age(age: i32) -> Result { + if (0..=150).contains(&age) { + Ok(age) + } else { + Err(ValidationError::InvalidAge(age)) + } +} + +#[derive(Debug)] +struct User { email: String, age: i32 } + +// ? operator — propagates Err automatically +fn parse_user(email: &str, age: i32) -> Result { + let valid_email = validate_email(email)?; // returns Err if Err + let valid_age = validate_age(age)?; + Ok(User { email: valid_email, age: valid_age }) +} + +// map / and_then for pipeline style +fn process_email(raw: &str) -> Result { + validate_email(raw) + .map(|e| e.to_uppercase()) + .and_then(|e| if e.len() > 5 { Ok(e) } else { Err(ValidationError::InvalidEmail(e)) }) +} + +// Pattern match at call site +match parse_user("alice@example.com", 30) { + Ok(user) => println!("User: {:?}", user), + Err(e) => eprintln!("Error: {}", e), +} +``` + +--- + +## Algebraic Data Types — Enums + +```rust +// Rust enums are full algebraic data types — each variant can carry data + +#[derive(Debug)] +enum OrderStatus { + Pending, + Shipped { tracking_code: String }, + Delivered { delivered_at: chrono::DateTime }, + Cancelled { reason: String }, +} + +// Exhaustive pattern matching — compiler error if a case is missing +fn describe_status(status: &OrderStatus) -> String { + match status { + OrderStatus::Pending => "Awaiting shipment".to_string(), + OrderStatus::Shipped { tracking_code } => format!("Shipped — {tracking_code}"), + OrderStatus::Delivered { delivered_at } => format!("Delivered on {delivered_at}"), + OrderStatus::Cancelled { reason } => format!("Cancelled: {reason}"), + } +} + +// Recursive enum (Box needed for size known at compile time) +#[derive(Debug)] +enum Tree { + Leaf, + Node { value: A, left: Box>, right: Box> }, +} + +fn depth(tree: &Tree) -> usize { + match tree { + Tree::Leaf => 0, + Tree::Node { left, right, .. } => 1 + depth(left).max(depth(right)), + } +} +``` + +--- + +## Function Composition + +```rust +// Rust has no built-in compose/pipe operator, but closures compose naturally + +fn compose(f: impl Fn(A) -> B, g: impl Fn(B) -> C) -> impl Fn(A) -> C { + move |x| g(f(x)) +} + +let normalize = |s: &str| s.trim().to_lowercase(); +let validate = |s: String| if s.contains('@') { Some(s) } else { None }; + +// Iterator chains ARE the pipeline +let result = raw_emails.iter() + .map(|e| e.trim().to_lowercase()) + .filter(|e| e.contains('@')) + .map(|e| format!("<{e}>")) + .collect::>(); +``` + +--- + +## Newtype Pattern — Nominal Typing + +```rust +// Wrapping primitives prevents mixing up different domain concepts + +struct UserId(String); +struct OrderId(String); +struct Email(String); + +impl UserId { + fn new(id: impl Into) -> Self { Self(id.into()) } + fn as_str(&self) -> &str { &self.0 } +} + +fn get_user(id: &UserId) -> Option { + db.find_user(id.as_str()) +} + +let order_id = OrderId::new("order-123"); +// get_user(&order_id) — compile error! OrderId is not UserId +``` + +--- + +## Side Effect Boundary + +```rust +// Pure core — no I/O, fully testable +fn process_registration( + existing_emails: &std::collections::HashSet, + email: &str, + name: &str, + id: &str, +) -> Result { + let email = email.trim().to_lowercase(); + if !email.contains('@') { + return Err("Invalid email".to_string()); + } + if existing_emails.contains(&email) { + return Err("Email already registered".to_string()); + } + Ok(User { id: id.to_string(), email, name: name.to_string() }) +} + +// Impure shell — coordinates I/O +async fn register_user( + db: &Database, + email: &str, + name: &str, +) -> Result> { + let existing = db.all_emails().await?; // I/O + let id = uuid::Uuid::new_v4().to_string(); // I/O + let user = process_registration(&existing, email, name, &id) // pure + .map_err(|e| Box::::from(e))?; + db.save_user(&user).await?; // I/O + Ok(user) +} +``` diff --git a/skills/fp-best-practices/references/typescript-examples.md b/skills/fp-best-practices/references/typescript-examples.md new file mode 100644 index 0000000..b10aded --- /dev/null +++ b/skills/fp-best-practices/references/typescript-examples.md @@ -0,0 +1,326 @@ +# TypeScript — Functional Programming Examples + +Concepts covered: pure functions, pipe/compose, currying, Option, Either, immutable updates, higher-order functions, branded types, sum types, side effect boundary. + +Libraries: [fp-ts](https://gcanti.github.io/fp-ts/), [Effect-TS](https://effect.website/), [Ramda](https://ramdajs.com). + +--- + +## Pure Functions and Referential Transparency + +```typescript +// Impure — depends on external state +let taxRate = 0.21; +function calculateTax(price: number): number { + return price * taxRate; // hidden dependency +} + +// Pure — all inputs explicit +const calculateTax = (rate: number, price: number): number => price * rate; + +// Pure — immutable transform +type Cart = { items: Item[]; couponCode?: string }; +const addItem = (cart: Cart, item: Item): Cart => ({ + ...cart, + items: [...cart.items, item], +}); +``` + +--- + +## Pipe and Compose (fp-ts) + +```typescript +import { pipe, flow } from "fp-ts/function"; + +// pipe — apply a value through a sequence of functions (left-to-right) +const result = pipe( + " alice@example.com ", + (s) => s.trim(), + (s) => s.toLowerCase(), + (s) => s.replace(/\s+/g, "") +); + +// flow — create a reusable pipeline (same as pipe without the initial value) +const normalizeEmail = flow( + (s: string) => s.trim(), + (s) => s.toLowerCase() +); + +// Named functions compose cleanly +const processOrder = flow(filterActiveItems, calculateSubtotal, applyTax(0.21), formatCurrency); +``` + +--- + +## Currying and Partial Application + +```typescript +// Manual currying +const multiply = (a: number) => (b: number): number => a * b; +const double = multiply(2); +const triple = multiply(3); + +// With fp-ts +import { pipe } from "fp-ts/function"; +import * as R from "ramda"; + +const applyDiscount = R.curry((rate: number, price: number) => price * (1 - rate)); +const applyTenPercent = applyDiscount(0.1); + +[100, 200, 300].map(applyTenPercent); // [90, 180, 270] + +// Partial application pattern for dependency injection +const createUserService = (db: Database) => ({ + findById: (id: UserId) => db.users.findById(id), + save: (user: User) => db.users.save(user), +}); +``` + +--- + +## Branded / Nominal Types + +```typescript +// Without branding — compiler accepts wrong types silently +type UserId = string; +type OrderId = string; +const getUser = (id: UserId): Promise => db.users.findById(id); +const orderId: OrderId = "order-123"; +getUser(orderId); // No error — but wrong! + +// With branding — compiler rejects wrong types +type UserId = string & { readonly _brand: "UserId" }; +type OrderId = string & { readonly _brand: "OrderId" }; + +const makeUserId = (id: string): UserId => id as UserId; +const makeOrderId = (id: string): OrderId => id as OrderId; + +const getUser = (id: UserId): Promise => db.users.findById(id); +const orderId = makeOrderId("order-123"); +getUser(orderId); // Type error! OrderId is not UserId +``` + +--- + +## Option — Explicit Absence (fp-ts) + +```typescript +import { pipe } from "fp-ts/function"; +import * as O from "fp-ts/Option"; + +type Address = { street: string; city?: string }; +type User = { name: string; address?: Address }; + +// Wrap nullable values +const findUser = (id: string): O.Option => + O.fromNullable(db.users.get(id)); + +// Chain optional access — short-circuits on None +const getCity = (user: User): O.Option => + pipe( + O.fromNullable(user.address), + O.chain((addr) => O.fromNullable(addr.city)) + ); + +// Match to extract the value +const displayCity = (userId: string): string => + pipe( + findUser(userId), + O.chain(getCity), + O.match( + () => "City unknown", + (city) => city + ) + ); + +// getOrElse for a default +const cityOrDefault = (user: User): string => + pipe(getCity(user), O.getOrElse(() => "Unknown")); +``` + +--- + +## Either — Explicit Failure (fp-ts) + +```typescript +import { pipe } from "fp-ts/function"; +import * as E from "fp-ts/Either"; + +type ValidationError = { field: string; message: string }; +type User = { email: string; age: number }; + +const validateEmail = (email: string): E.Either => + email.includes("@") + ? E.right(email.toLowerCase().trim()) + : E.left({ field: "email", message: "Must contain @" }); + +const validateAge = (age: number): E.Either => + age >= 0 && age <= 150 + ? E.right(age) + : E.left({ field: "age", message: "Must be between 0 and 150" }); + +// Do-notation style — fails on first error +const parseUser = (input: { + email: string; + age: number; +}): E.Either => + pipe( + E.Do, + E.bind("email", () => validateEmail(input.email)), + E.bind("age", () => validateAge(input.age)) + ); + +// Usage +pipe( + parseUser({ email: "alice@example.com", age: 30 }), + E.match( + (err) => console.error(`${err.field}: ${err.message}`), + (user) => console.log("Valid:", user) + ) +); +``` + +--- + +## Sum Types — Discriminated Unions + +```typescript +// Each variant carries only its relevant data +type OrderStatus = + | { kind: "pending"; createdAt: Date } + | { kind: "shipped"; createdAt: Date; trackingCode: string } + | { kind: "delivered"; createdAt: Date; deliveredAt: Date } + | { kind: "cancelled"; createdAt: Date; reason: string }; + +function describeStatus(status: OrderStatus): string { + switch (status.kind) { + case "pending": + return "Awaiting shipment"; + case "shipped": + return `Shipped — tracking: ${status.trackingCode}`; + case "delivered": + return `Delivered on ${status.deliveredAt.toDateString()}`; + case "cancelled": + return `Cancelled: ${status.reason}`; + } + // TypeScript exhaustiveness — if a new case is added, this becomes unreachable + // Add: default: status satisfies never; to enforce it at compile time +} +``` + +--- + +## Immutable Data Transforms + +```typescript +// Spread for shallow updates +const updateEmail = (user: User, email: string): User => ({ ...user, email }); + +// Nested immutable update +type Profile = { user: User; address: Address }; +const updateCity = (profile: Profile, city: string): Profile => ({ + ...profile, + address: { ...profile.address, city }, +}); + +// With Ramda lenses — deep updates without manual spread nesting +import * as R from "ramda"; +const cityLens = R.lensPath(["address", "city"]); +const moveToMadrid = R.set(cityLens, "Madrid"); +const updated = moveToMadrid(profile); // original profile unchanged + +// readonly enforces immutability at the type level +type ReadonlyUser = Readonly; +type ReadonlyCart = { readonly items: ReadonlyArray }; +``` + +--- + +## Higher-Order Functions + +```typescript +type Order = { id: string; status: string; total: number; items: Item[] }; + +// map, filter, reduce +const activeOrders = orders.filter((o) => o.status === "active"); +const totals = activeOrders.map((o) => o.items.reduce((s, i) => s + i.price, 0)); +const grandTotal = totals.reduce((a, b) => a + b, 0); + +// flatMap — expand nested arrays +const allItems: Item[] = orders.flatMap((o) => o.items); + +// Generic HOF +const groupBy = + (fn: (item: T) => K) => + (items: T[]): Record => + items.reduce( + (acc, item) => { + const key = fn(item); + return { ...acc, [key]: [...(acc[key] ?? []), item] }; + }, + {} as Record + ); + +const byStatus = groupBy((o: Order) => o.status)(orders); +``` + +--- + +## Side Effect Boundary + +```typescript +// Pure core — no I/O, fully testable +type RegistrationInput = { email: string; name: string }; +type RegistrationResult = E.Either; + +function processRegistration( + existingEmails: Set, + input: RegistrationInput, + now: Date, + id: string +): RegistrationResult { + const email = input.email.toLowerCase().trim(); + if (!email.includes("@")) return E.left("Invalid email"); + if (existingEmails.has(email)) return E.left("Already registered"); + return E.right({ id, email, name: input.name, createdAt: now }); +} + +// Impure shell — minimal I/O coordination +async function registerUser(input: RegistrationInput): Promise { + const existingEmails = await db.users.allEmails(); // I/O + const id = crypto.randomUUID(); // I/O (randomness) + const now = new Date(); // I/O (time) + const result = processRegistration(existingEmails, input, now, id); // pure + if (E.isRight(result)) await db.users.save(result.right); // I/O + return result; +} +``` + +--- + +## Effect-TS (typed effects and dependencies) + +```typescript +import { Effect, pipe } from "effect"; +import { Layer } from "effect"; + +// Effect +interface Database { + findUser: (id: string) => Effect.Effect; +} + +const getUser = (id: string): Effect.Effect => + Effect.serviceWith( + (db: Database) => db.findUser(id) + ); + +const program = pipe( + getUser("user-123"), + Effect.map((user) => ({ ...user, lastSeen: new Date() })), + Effect.flatMap(saveUser) +); + +// Run with a concrete database implementation +Effect.runPromise(Effect.provide(program, DatabaseLive)); +``` diff --git a/tests/inventory.test.mjs b/tests/inventory.test.mjs index 38956b3..bd891e1 100644 --- a/tests/inventory.test.mjs +++ b/tests/inventory.test.mjs @@ -31,11 +31,12 @@ test('live skills are unique and include the three original packages plus BotKit assert.ok(names.includes('setup-bot')); assert.ok(names.includes('retro')); assert.ok(names.includes('simple-as-writing')); + assert.ok(names.includes('fp-best-practices')); assert.ok(names.includes('tdd')); assert.ok(names.includes('matt-tdd')); assert.ok(names.includes('teach')); assert.ok(names.includes('matt-teach')); - assert.equal(names.length, 96); + assert.equal(names.length, 97); }); test('pinned original sources remain present', async () => {