Practical Applications of Functional Programming

Functional programming is most useful when it gives ordinary application code clear boundaries. Pure functions transform trusted values. Small adapters own network, storage, clock, and engine effects. The boundary between them validates untrusted data instead of pretending that a type annotation changed runtime input.

Learning Goals

By the end of this lecture, you should be able to:

Start with Ownership and Boundaries

A practical application commonly contains this flow:

external input -> effectful adapter -> decoder -> pure domain transformation
                                      |
                                      +-> explicit error value

pure result -> RTK event, RTK Query cache, ECS command, UI, or output adapter

Keep the functional layer independent of the systems that consume it:

        functional core
         /          \
Redux Toolkit      ECS
client workflow    frame simulation

The functional core may define parsers, validators, predicates, and transformations. Redux Toolkit and ECS may call those functions. The core should not import a store, reducer, world, entity, component, or system.

Choose an owner before choosing an abstraction:

Kind of state Normal owner
One component’s open panel React component state
Shareable navigation or filters URL or router
Server-owned documents and request status RTK Query
Shared durable client workflow Redux Toolkit slice
High-frequency spatial or simulation data ECS world
Intermediate values inside a transformation Local function scope

Represent Expected Failure as Plain Data

The examples in this lecture share a small Either representation. It uses no custom class instance or function-valued methods, so it remains suitable for Redux state or actions when its error and success values are recursively plain serializable data.

export type Either<E, T> =
  | { readonly _tag: 'Left'; readonly error: E }
  | { readonly _tag: 'Right'; readonly value: T }

export const left = <E>(error: E): Either<E, never> => ({
  _tag: 'Left',
  error,
})

export const right = <T>(value: T): Either<never, T> => ({
  _tag: 'Right',
  value,
})

export interface User {
  readonly id: string
  readonly name: string
  readonly email: string
}

export type DecodeError = {
  readonly kind: 'decode'
  readonly message: string
}

export const decodeUser = (input: unknown): Either<DecodeError, User> => {
  if (typeof input !== 'object' || input === null) {
    return left({ kind: 'decode', message: 'Expected a user object' })
  }

  if (
    !('id' in input) ||
    typeof input.id !== 'string' ||
    !('name' in input) ||
    typeof input.name !== 'string' ||
    !('email' in input) ||
    typeof input.email !== 'string'
  ) {
    return left({
      kind: 'decode',
      message: 'Expected string id, name, and email fields',
    })
  }

  return right({ id: input.id, name: input.name, email: input.email })
}

export const decodeUsers = (
  input: unknown,
): Either<DecodeError, readonly User[]> => {
  if (!Array.isArray(input)) {
    return left({ kind: 'decode', message: 'Expected an array of users' })
  }

  const users: User[] = []

  for (const [index, value] of input.entries()) {
    const decoded = decodeUser(value)

    if (decoded._tag === 'Left') {
      return left({
        kind: 'decode',
        message: `users[${index}]: ${decoded.error.message}`,
      })
    }

    users.push(decoded.value)
  }

  return right(users)
}

A TypeScript generic describes a compile-time contract. Only a decoder checks whether an HTTP response, saved file, message, or engine payload satisfies that contract at runtime.

Accumulate Every Error with Validation

decodeUsers above is fail-fast: it returns at the first bad element, so a response with ten malformed records reports one error and hides the other nine. That is the right behavior for dependent steps, but decoding a list is a set of independent checks — the natural shape is applicative, collecting every failure.

Validation<E, T> is Either’s shape with an accumulating combine and no chain. Traversing the array with it reports all offending indices at once:

export type Validation<E, T> =
  | { readonly _tag: 'Failure'; readonly errors: readonly E[] }
  | { readonly _tag: 'Success'; readonly value: T }

// Traverse: Array<A> + (A -> Either<E, B>) -> Validation<E, B[]>, accumulating.
export const decodeUsersAll = (
  input: unknown,
): Validation<string, readonly User[]> => {
  if (!Array.isArray(input)) {
    return { _tag: 'Failure', errors: ['Expected an array of users'] }
  }

  return input.reduce<Validation<string, User[]>>((acc, value, index) => {
    const decoded = decodeUser(value)
    const label = `users[${index}]: `
    // Both sides collected: keep accumulating errors, keep building values.
    if (decoded._tag === 'Left') {
      const errors = acc._tag === 'Failure' ? acc.errors : []
      return { _tag: 'Failure', errors: [...errors, label + decoded.error.message] }
    }
    return acc._tag === 'Failure'
      ? acc
      : { _tag: 'Success', value: [...acc.value, decoded.value] }
  }, { _tag: 'Success', value: [] })
}

Choose per boundary: fail-fast Either when the caller acts on the first problem (a pipeline that cannot continue), accumulating Validation when a human needs to see every problem at once (a form, an import file, a config). The two agree until more than one thing is wrong, and that difference is a product decision.

Web Development

React and RTK Query

React components describe UI from props, state, and context. Keep a presentational component unaware of data fetching. Use RTK Query as the default owner for server data, and ensure its endpoint validates the response before a component receives User.

import { useGetUserQuery } from '../services/api'
import type { User } from '../domain/user'

export const UserCard = ({ user }: { user: User }) => (
  <article className="user-card">
    <h3>{user.name}</h3>
    <p>{user.email}</p>
  </article>
)

export const UserPanel = ({ userId }: { userId: string }) => {
  const { data: user, isLoading, isError } = useGetUserQuery(userId)

  if (isLoading) return <p>Loading…</p>
  if (isError || user === undefined) {
    return <p>Unable to load this user.</p>
  }

  return <UserCard user={user} />
}

For one backend, prefer one createApi root and split feature endpoints with injectEndpoints. Register both the API reducer and middleware. Use tags for normal invalidation and keep optimistic cache patches in endpoint lifecycle handlers, where rollback remains coupled to the request.

Do not copy query results and loading flags into an ordinary slice. That creates two authorities for the same server document.

Shared Client State with Redux Toolkit

Redux Toolkit slices are appropriate for shared, durable client state. Actions should describe events. Reducers own transitions from current state plus event data, and selectors derive views instead of storing duplicate values.

import {
  configureStore,
  createSelector,
  createSlice,
  type PayloadAction,
} from '@reduxjs/toolkit'
import { useDispatch, useSelector } from 'react-redux'
import { api } from '../services/api'

interface Todo {
  readonly id: string
  readonly title: string
  readonly completed: boolean
}

interface TodoState {
  readonly items: readonly Todo[]
}

const initialState: TodoState = { items: [] }

const todosSlice = createSlice({
  name: 'todos',
  initialState,
  reducers: {
    todoAdded(
      state,
      action: PayloadAction<{ readonly id: string; readonly title: string }>,
    ) {
      state.items.push({ ...action.payload, completed: false })
    },
    todoToggled(state, action: PayloadAction<{ readonly id: string }>) {
      const todo = state.items.find((item) => item.id === action.payload.id)
      if (todo) todo.completed = !todo.completed
    },
  },
})

export const store = configureStore({
  reducer: {
    todos: todosSlice.reducer,
    [api.reducerPath]: api.reducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(api.middleware),
})

export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch

export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()

const selectTodos = (state: RootState) => state.todos.items

export const selectCompletedCount = createSelector(
  [selectTodos],
  (todos) => todos.filter((todo) => todo.completed).length,
)

export const { todoAdded, todoToggled } = todosSlice.actions

Immer lets a slice reducer use mutation syntax while producing immutable state. That does not make effects legal in a reducer: no HTTP calls, clocks, random ID generation, logging, or mutation of values outside the draft.

Keep Redux actions and state as plain serializable data. A tagged Either value can cross that boundary if both branches contain serializable data. An object whose .map() method is a function cannot.

Data Processing

A Pure ETL Core

A CSV library might produce string-valued rows. Once that adapter has done I/O, the domain can validate and transform each row with a pure function.

type CsvRow = Readonly<Record<string, string | undefined>>

interface ProcessedData {
  readonly id: number
  readonly name: string
  readonly value: number
  readonly timestamp: string
}

type RowError = {
  readonly kind: 'invalid-row'
  readonly message: string
}

const processRow = (row: CsvRow): Either<RowError, ProcessedData> => {
  const idText = row.id?.trim()
  const name = row.name?.trim()
  const valueText = row.value?.trim()
  const timestamp = row.timestamp?.trim()

  if (!idText || !name || !valueText || !timestamp) {
    return left({ kind: 'invalid-row', message: 'Missing a required field' })
  }

  if (!/^-?\d+$/.test(idText)) {
    return left({ kind: 'invalid-row', message: `Invalid id: ${idText}` })
  }

  const id = Number(idText)
  const value = Number(valueText)
  const milliseconds = Date.parse(timestamp)

  if (!Number.isSafeInteger(id)) {
    return left({ kind: 'invalid-row', message: `Unsafe id: ${idText}` })
  }

  if (!Number.isFinite(value)) {
    return left({ kind: 'invalid-row', message: `Invalid value: ${valueText}` })
  }

  if (Number.isNaN(milliseconds)) {
    return left({ kind: 'invalid-row', message: `Invalid date: ${timestamp}` })
  }

  return right({
    id,
    name,
    value,
    timestamp: new Date(milliseconds).toISOString(),
  })
}

interface RejectedRow {
  readonly row: CsvRow
  readonly error: RowError
}

interface BatchResult {
  readonly accepted: readonly ProcessedData[]
  readonly rejected: readonly RejectedRow[]
}

const processBatch = (rows: readonly CsvRow[]): BatchResult => {
  const accepted: ProcessedData[] = []
  const rejected: RejectedRow[] = []

  for (const row of rows) {
    const result = processRow(row)

    if (result._tag === 'Right') {
      accepted.push(result.value)
    } else {
      rejected.push({ row, error: result.error })
    }
  }

  return { accepted, rejected }
}

This batch deliberately accumulates rejected rows. A command-validation pipeline might instead return the first Left. Neither behavior is universally correct; collection versus fail-fast is part of the contract. Its local arrays never escape until the final return, so this implementation stays observable as a pure function without repeatedly copying a growing batch.

Reading the CSV and writing accepted records remain effects:

interface ImportPorts {
  readRows: () => Promise<readonly CsvRow[]>
  writeRows: (rows: readonly ProcessedData[]) => Promise<void>
}

const runImport = async (
  ports: ImportPorts,
): Promise<readonly RejectedRow[]> => {
  const rows = await ports.readRows()
  const batch = processBatch(rows)
  await ports.writeRows(batch.accepted)
  return batch.rejected
}

This is a functional core inside an imperative shell. Tests for processRow need no database mock; integration tests for runImport can supply small port implementations.

An API Client That Decodes unknown

If an application does not use RTK Query for a request, the same boundary rule still applies. Pass a decoder to the effectful client instead of writing request<User>(), which would only assert an expectation.

type ApiError =
  | { readonly kind: 'network'; readonly message: string }
  | { readonly kind: 'http'; readonly status: number; readonly message: string }
  | { readonly kind: 'decode'; readonly message: string }

type Decoder<T> = (input: unknown) => Either<DecodeError, T>

const errorMessage = (error: unknown): string =>
  error instanceof Error ? error.message : 'Unknown failure'

class ApiClient {
  private async request<T>(
    url: string,
    decode: Decoder<T>,
    options?: RequestInit,
  ): Promise<Either<ApiError, T>> {
    let response: Response

    try {
      response = await fetch(url, options)
    } catch (error: unknown) {
      return left({ kind: 'network', message: errorMessage(error) })
    }

    if (!response.ok) {
      return left({
        kind: 'http',
        status: response.status,
        message: response.statusText,
      })
    }

    let payload: unknown

    try {
      payload = await response.json()
    } catch (error: unknown) {
      return left({
        kind: 'decode',
        message: `Invalid JSON: ${errorMessage(error)}`,
      })
    }

    const decoded = decode(payload)
    return decoded._tag === 'Right'
      ? right(decoded.value)
      : left({ kind: 'decode', message: decoded.error.message })
  }

  getUsers(): Promise<Either<ApiError, readonly User[]>> {
    return this.request('/api/users', decodeUsers)
  }

  createUser(user: Omit<User, 'id'>): Promise<Either<ApiError, User>> {
    return this.request('/api/users', decodeUser, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(user),
    })
  }
}

This client distinguishes transport, HTTP, and decoding failures. Domain rules can add another error vocabulary after decoding instead of collapsing every failure into one string.

Test Laws and Boundaries

Pure functions support example tests and broader properties. State the input domain so the property is actually true; JavaScript number arithmetic is not mathematical real-number arithmetic.

import * as fc from 'fast-check'
import { describe, expect, it } from 'vitest'

const normalizeName = (value: string): string =>
  value.trim().replace(/\s+/gu, ' ')

describe('functional core', () => {
  it('normalizes names idempotently', () => {
    fc.assert(
      fc.property(fc.string(), (value) => {
        const once = normalizeName(value)
        return normalizeName(once) === once
      }),
    )
  })

  it('rejects malformed users at the boundary', () => {
    expect(decodeUser({ id: 'u1', name: 'Ada' })).toEqual({
      _tag: 'Left',
      error: {
        kind: 'decode',
        message: 'Expected string id, name, and email fields',
      },
    })
  })

  it('parses a valid CSV row', () => {
    expect(
      processRow({
        id: '7',
        name: 'Ada',
        value: '2.5',
        timestamp: '2026-01-02T00:00:00.000Z',
      }),
    ).toEqual(
      right({
        id: 7,
        name: 'Ada',
        value: 2.5,
        timestamp: '2026-01-02T00:00:00.000Z',
      }),
    )
  })
})

Test both branches of a decoder, reducer, or tagged value. Avoid extracting a success with a fake null default or a non-null assertion; branch on the tag so TypeScript and the test both prove which value exists.

Performance Policies

Memoize Only Pure Work

Memoization is sound when output depends only on the key and retained entries remain valid. Wrapping the cached value avoids a non-null assertion and still supports functions whose result is undefined.

const memoize = <A, R>(transform: (argument: A) => R) => {
  const cache = new Map<A, { readonly value: R }>()

  return (argument: A): R => {
    const cached = cache.get(argument)
    if (cached) return cached.value

    const entry = { value: transform(argument) }
    cache.set(argument, entry)
    return entry.value
  }
}

const cube = memoize((value: number) => value * value * value)

cube(5) // computes 125
cube(5) // reuses 125

This cache is intentionally simple, not universally safe:

Use createSelector for derived Redux views. Use RTK Query for server request deduplication, subscription lifetimes, errors, and invalidation. Use a bounded cache with an explicit key and eviction policy for general application work.

Lazy Values Hide a Small Effect

A memoized lazy value defers a synchronous computation and runs it at most once after a successful evaluation.

class Lazy<T> {
  private state:
    | { status: 'pending'; evaluate: () => T }
    | { status: 'ready'; value: T }

  private constructor(evaluate: () => T) {
    this.state = { status: 'pending', evaluate }
  }

  static of<T>(value: T): Lazy<T> {
    return new Lazy(() => value)
  }

  static from<T>(evaluate: () => T): Lazy<T> {
    return new Lazy(evaluate)
  }

  map<U>(transform: (value: T) => U): Lazy<U> {
    return Lazy.from(() => transform(this.get()))
  }

  get(): T {
    if (this.state.status === 'pending') {
      const value = this.state.evaluate()
      this.state = { status: 'ready', value }
      return value
    }

    return this.state.value
  }
}

const answer = Lazy.from(() => 21).map((value) => value * 2)
answer.get() // 42
answer.get() // the cached 42

The state transition inside get() is an effect hidden behind the abstraction. This version is synchronous, retries if evaluation throws, has no cancellation, and makes no cross-thread guarantee. An asynchronous workflow needs a real Promise, task, or runtime policy rather than a renamed callback registry.

Immutable Configuration

Configuration builders are useful when defaults and override precedence are explicit. Partial<Config> is not enough for nested overrides because it still requires a complete features object, so define the boundary shape directly.

interface Config {
  readonly apiUrl: string
  readonly timeout: number
  readonly retries: number
  readonly features: {
    readonly caching: boolean
    readonly logging: boolean
  }
}

type ConfigOverrides = Partial<Omit<Config, 'features'>> & {
  readonly features?: Partial<Config['features']>
}

const createConfig = (overrides: ConfigOverrides = {}): Config => {
  const { features, ...topLevel } = overrides

  return Object.freeze({
    apiUrl: 'https://api.example.com',
    timeout: 5_000,
    retries: 3,
    ...topLevel,
    features: Object.freeze({
      caching: true,
      logging: false,
      ...features,
    }),
  })
}

const development = createConfig({
  apiUrl: 'https://dev-api.example.com',
  features: { logging: true },
})

const production = createConfig({ timeout: 10_000, retries: 5 })

The ordering is intentional: caller overrides win over defaults, and nested feature overrides win only inside features. readonly protects TypeScript callers; Object.freeze also protects these two object levels at runtime.

Exercise

Build an import workflow that reads CSV rows, decodes and validates them with a pure core, and writes accepted records through an effectful port. Then expose the latest import summary as shared client state with an event-style RTK action. Keep raw server documents in RTK Query and high-frequency progress particles in an ECS world. Test the core separately from the adapters.

Resources