Functional Programming Fundamentals
This skill covers the foundational concepts of functional programming. Master these before diving into fp-ts types like Option, Either, and Task. These concepts apply universally across functional programming languages and libraries.
Why Functional Programming?
Functional programming provides:
- Predictability: Pure functions always produce the same output for the same input
- Testability: No mocking of global state or dependencies
- Composability: Small functions combine into complex behavior
- Maintainability: Isolated pieces are easier to understand and change
- Parallelization: Pure functions can safely run concurrently
1. Pure Functions and Referential Transparency
What is a Pure Function?
A pure function has two properties:
- Same input, same output: Given the same arguments, it always returns the same result
- No side effects: It doesn't modify external state, perform I/O, or cause observable changes
Why It Matters
Pure functions are:
- Cacheable: Results can be memoized since they're deterministic
- Portable: No hidden dependencies on external state
- Testable: No setup/teardown of global state needed
- Parallelizable: Safe to run concurrently without race conditions
- Reasoned about locally: You can understand them without knowing the entire system
Examples
typescript
// PURE: Same input always gives same output
const add = (x: number, y: number): number => x + y
const greet = (name: string): string => `Hello, ${name}!`
const head = <T>(arr: readonly T[]): T | undefined => arr[0]
// IMPURE: Depends on external state
let counter = 0
const incrementCounter = (): number => {
counter += 1 // Mutates external state
return counter
}
// IMPURE: Different output for same input
const randomNumber = (): number => Math.random()
// IMPURE: Performs I/O
const logMessage = (msg: string): void => {
console.log(msg) // Side effect: writes to console
}
// IMPURE: Depends on mutable external state
const config = { apiUrl: 'https://api.example.com' }
const getApiUrl = (): string => config.apiUrl // Could return different values
Referential Transparency
An expression is referentially transparent if it can be replaced with its value without changing program behavior.
typescript
// Referentially transparent
const double = (x: number): number => x * 2
const result = double(5) + double(5)
// Can be replaced with: 10 + 10 = 20
// NOT referentially transparent
let count = 0
const incrementAndGet = (): number => {
count += 1
return count
}
const result2 = incrementAndGet() + incrementAndGet()
// Cannot replace with values: first call returns 1, second returns 2
// Result is 3, not 1 + 1 = 2
Making Impure Functions Pure
typescript
// IMPURE: Modifies input
const addItem = (arr: string[], item: string): void => {
arr.push(item) // Mutates the original array
}
// PURE: Returns new array
const addItem = (arr: readonly string[], item: string): readonly string[] =>
[...arr, item]
// IMPURE: Depends on Date
const getAge = (birthYear: number): number =>
new Date().getFullYear() - birthYear
// PURE: Accept date as parameter (dependency injection)
const getAge = (birthYear: number, currentYear: number): number =>
currentYear - birthYear
// IMPURE: Reads from database
const getUser = async (id: string): Promise<User> =>
await database.find(id)
// Functional approach: Return a description of the effect
// (This is the concept behind Task and IO in fp-ts)
const getUser = (id: string): Task<User> =>
() => database.find(id)
Common Mistakes
typescript
// MISTAKE 1: Mutating arguments
const sortArray = (arr: number[]): number[] => {
return arr.sort((a, b) => a - b) // sort() mutates the original!
}
// FIX: Create a copy first
const sortArray = (arr: readonly number[]): number[] =>
[...arr].sort((a, b) => a - b)
// MISTAKE 2: Using Date, Math.random, or other non-deterministic APIs
const generateId = (): string =>
`id-${Date.now()}-${Math.random()}`
// FIX: Accept dependencies as parameters
const generateId = (timestamp: number, random: number): string =>
`id-${timestamp}-${random}`
// MISTAKE 3: Hidden dependencies via closure
const multiplier = 2
const double = (x: number): number => x * multiplier // Captures external variable
// FIX: Make dependencies explicit
const multiply = (factor: number) => (x: number): number => x * factor
const double = multiply(2)
2. Currying and Partial Application
What is Currying?
Currying transforms a function that takes multiple arguments into a sequence of functions that each take a single argument.
typescript
// Uncurried: takes all arguments at once
const add = (x: number, y: number, z: number): number => x + y + z
add(1, 2, 3) // 6
// Curried: takes one argument at a time
const addCurried = (x: number) => (y: number) => (z: number): number => x + y + z
addCurried(1)(2)(3) // 6
Why It Matters
Currying enables:
- Partial application: Create specialized functions by supplying some arguments
- Composition: Single-argument functions compose cleanly
- Reusability: Create families of related functions from one definition
- Deferred computation: Supply arguments as they become available
Currying in Practice
typescript
// Curried function definition
const multiply = (x: number) => (y: number): number => x * y
// Create specialized functions through partial application
const double = multiply(2)
const triple = multiply(3)
double(5) // 10
triple(5) // 15
// Curried comparison helpers
const greaterThan = (threshold: number) => (value: number): boolean =>
value > threshold
const isAdult = greaterThan(17)
const isExpensive = greaterThan(100)
isAdult(25) // true
isExpensive(50) // false
Currying for Array Methods
typescript
// Curried filter predicate
const filterBy = <T>(predicate: (item: T) => boolean) =>
(items: readonly T[]): T[] => items.filter(predicate)
// Create reusable filters
const keepPositive = filterBy((n: number) => n > 0)
const keepEven = filterBy((n: number) => n % 2 === 0)
keepPositive([-1, 2, -3, 4]) // [2, 4]
keepEven([1, 2, 3, 4, 5, 6]) // [2, 4, 6]
// Curried map transformer
const mapWith = <A, B>(fn: (a: A) => B) =>
(items: readonly A[]): B[] => items.map(fn)
const doubleAll = mapWith((n: number) => n * 2)
const stringify = mapWith(String)
doubleAll([1, 2, 3]) // [2, 4, 6]
stringify([1, 2, 3]) // ['1', '2', '3']
Partial Application vs Currying
typescript
// Partial application: fixing some arguments of a function
const greet = (greeting: string, name: string): string =>
`${greeting}, ${name}!`
// Using bind for partial application (uncurried)
const sayHello = greet.bind(null, 'Hello')
sayHello('Alice') // "Hello, Alice!"
// Curried version is naturally partially applicable
const greetCurried = (greeting: string) => (name: string): string =>
`${greeting}, ${name}!`
const sayHello = greetCurried('Hello')
const sayGoodbye = greetCurried('Goodbye')
sayHello('Alice') // "Hello, Alice!"
sayGoodbye('Bob') // "Goodbye, Bob!"
Argument Order Matters
typescript
// BAD: Data first prevents easy partial application
const map = <A, B>(arr: A[], fn: (a: A) => B): B[] => arr.map(fn)
// Must use anonymous function to specialize
const doubleAll = (arr: number[]) => map(arr, n => n * 2)
// GOOD: Data last enables clean partial application
const map = <A, B>(fn: (a: A) => B) => (arr: readonly A[]): B[] => arr.map(fn)
// Specialize by supplying the function
const doubleAll = map((n: number) => n * 2)
const stringify = map(String)
// This is why fp-ts uses data-last curried functions
import * as A from 'fp-ts/Array'
import { pipe } from 'fp-ts/function'
pipe(
[1, 2, 3],
A.map(n => n * 2), // A.map takes fn first, returns function expecting array
A.filter(n => n > 2) // Same pattern
)
Common Mistakes
typescript
// MISTAKE 1: Inconsistent argument order
const process = (config: Config) => (data: Data) => (options: Options) => ...
const handle = (data: Data) => (config: Config) => ... // Inconsistent!
// FIX: Establish convention - typically: config -> options -> data
const process = (config: Config) => (options: Options) => (data: Data) => ...
const handle = (config: Config) => (data: Data) => ...
// MISTAKE 2: Over-currying simple functions
const add = (a: number) => (b: number) => (c: number) => (d: number) =>
a + b + c + d
add(1)(2)(3)(4) // Tedious to call
// FIX: Curry only to the level needed for your use case
const add = (a: number, b: number, c: number, d: number) => a + b + c + d
// Or group related parameters
const addPairs = (a: number, b: number) => (c: number, d: number) =>
a + b + c + d
// MISTAKE 3: Forgetting to curry when using with pipe
const formatPrice = (currency: string, amount: number): string =>
`${currency}${amount.toFixed(2)}`
pipe(
100,
formatPrice('$', ???) // Can't use in pipe - needs both args!
)
// FIX: Curry with data last
const formatPrice = (currency: string) => (amount: number): string =>
`${currency}${amount.toFixed(2)}`
pipe(
100,
formatPrice('$') // Works!
) // "$100.00"
3. Function Composition
What is Function Composition?
Function composition combines two or more functions to create a new function. The output of one function becomes the input of the next.
typescript
// Mathematical composition: (f . g)(x) = f(g(x))
const compose = <A, B, C>(f: (b: B) => C, g: (a: A) => B) =>
(x: A): C => f(g(x))
const addOne = (x: number): number => x + 1
const double = (x: number): number => x * 2
const addOneThenDouble = compose(double, addOne)
addOneThenDouble(5) // double(addOne(5)) = double(6) = 12
Why It Matters
Composition enables:
- Building complex behavior from simple pieces: Small, focused functions combine into powerful transformations
- Reusability: Composed functions can themselves be composed further
- Readability: Each step has a clear, single purpose
- Testing: Test small pieces in isolation, composition is mathematically guaranteed
Pipe vs Flow (Left-to-Right Composition)
Traditional mathematical composition reads right-to-left, which can be confusing. pipe and flow provide left-to-right composition.
typescript
import { pipe, flow } from 'fp-ts/function'
const addOne = (x: number): number => x + 1
const double = (x: number): number => x * 2
const toString = (x: number): string => `Value: ${x}`
// pipe: Execute immediately with a value
const result = pipe(
5,
addOne, // 6
double, // 12
toString // "Value: 12"
)
// flow: Create a reusable function
const transform = flow(
addOne,
double,
toString
)
transform(5) // "Value: 12"
transform(10) // "Value: 22"
Composition with Multiple Types
typescript
// Functions don't need matching types, just compatible connections
const parseNumber = (s: string): number => parseInt(s, 10)
const isEven = (n: number): boolean => n % 2 === 0
const toYesNo = (b: boolean): string => b ? 'Yes' : 'No'
const isInputEven = flow(
parseNumber, // string -> number
isEven, // number -> boolean
toYesNo // boolean -> string
)
isInputEven('4') // "Yes"
isInputEven('7') // "No"
Building Pipelines
typescript
import { pipe, flow } from 'fp-ts/function'
interface User {
name: string
email: string
age: number
}
// Small, focused functions
const getName = (user: User): string => user.name
const toUpperCase = (s: string): string => s.toUpperCase()
const addGreeting = (name: string): string => `Hello, ${name}!`
const addExcitement = (s: string): string => `${s}!!!`
// Compose into a pipeline
const greetUser = flow(
getName,
toUpperCase,
addGreeting,
addExcitement
)
greetUser({ name: 'alice', email: 'alice@example.com', age: 30 })
// "Hello, ALICE!!!!"