Light Functional Programming Guide for TypeScript Teams
The Problem with Complex Codebases
Modern TypeScript applications often suffer from a mixture of object-oriented and functional paradigms that create confusion about data modeling, error handling, and dependency management. Teams struggle with:
- Mixed metaphors: Some code uses classes, others use functions
- Unclear error handling: Mix of exceptions and return values
- Tight coupling: Direct dependencies on external services
- Mutable state: Hard to reason about data transformations
The result is code that's difficult to test, maintain, and reason about.
What is Light Functional Programming?
Light FP provides the benefits of functional programming without the learning curve of complex FP frameworks. It focuses on three core principles:
- Model with types first; make illegal states unrepresentable
- Keep the core pure (no I/O); push effects to edges
- Treat errors as values (
Result<T,E>
)
This approach gives you 80% of FP benefits with 20% of the complexity.
The Type System: Types vs Interfaces
The foundation of Light FP is a clear distinction between data and behavior:
Use type
for Data Definitions
All data structures should be immutable types with readonly
properties:
// ✅ CORRECT: Data as immutable types
export type User = {
readonly id: string;
readonly name: string;
readonly email: string;
readonly createdAt: Date;
};
export type PaymentStatus =
| "pending"
| "processing"
| "completed"
| "failed";
export type ApiResponse<T> =
| { readonly success: true; readonly data: T }
| { readonly success: false; readonly error: string };
Use interface
ONLY for Ports
Interfaces are reserved exclusively for defining behavioral contracts - capabilities your system needs:
// ✅ CORRECT: Interfaces for capabilities
export interface Clock {
readonly now: () => Date;
readonly timestamp: () => number;
}
export interface UserRepository {
readonly save: (user: User) => Promise<Result<User, DatabaseError>>;
readonly findById: (id: string) => Promise<Result<User | null, DatabaseError>>;
}
export interface Logger {
readonly info: (message: string, data?: unknown) => void;
readonly error: (message: string, data?: unknown) => void;
}
Anti-Patterns to Avoid
// ❌ WRONG: Interface for data
interface UserData {
id: string;
name: string;
}
// ❌ WRONG: Mutable properties
export type User = {
id: string; // Missing readonly
name: string; // Missing readonly
};
// ❌ WRONG: Classes for data
class User {
constructor(public id: string, public name: string) {}
}
Error Handling with Result Types
Replace exceptions with explicit error handling using Result types:
// Core Result type
export type Result<T, E> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: E };
// Constructor functions
export const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
export const err = <E>(error: E): Result<never, E> => ({ ok: false, error });
// Domain-specific errors
export type ValidationError =
| { readonly type: "required"; readonly field: string }
| { readonly type: "invalid_email"; readonly field: string }
| { readonly type: "too_short"; readonly field: string; readonly minLength: number };
Result in Practice
// Before: Exception-based
function parseEmail(input: string): string {
if (!input.includes("@")) {
throw new Error("Invalid email");
}
return input;
}
// After: Result-based
function parseEmail(input: string): Result<string, ValidationError> {
if (!input.includes("@")) {
return err({ type: "invalid_email", field: "email" });
}
return ok(input);
}
The Ports Pattern: Dependency Injection Done Right
The ports pattern separates business logic from infrastructure concerns through dependency injection:
Directory Structure
src/
domain/ # Pure business logic
ports/ # Interface definitions
clock.ts
logger.ts
database.ts
adapters/ # Port implementations
real-clock.ts
console-logger.ts
sqlite-db.ts
http/ # HTTP transport layer
Pure Domain Functions
// src/domain/user-service.ts
import type { Clock } from "../ports/clock.ts";
import type { Crypto } from "../ports/crypto.ts";
import type { Database } from "../ports/database.ts";
export const createUser =
(clock: Clock, crypto: Crypto, db: Database) =>
async (userData: CreateUserData): Promise<Result<User, CreateUserError>> => {
// Pure validation
const validation = validateUserData(userData);
if (!validation.ok) return validation;
// Create user with injected capabilities
const user: User = {
id: crypto.randomUUID(),
name: userData.name,
email: userData.email,
createdAt: clock.now(),
};
// Save using injected database
return await db.save(user);
};
Composition at Boundaries
// src/app/main.ts
import { createClock } from "../adapters/real-clock.ts";
import { createCrypto } from "../adapters/deno-crypto.ts";
import { createDatabase } from "../adapters/sqlite-db.ts";
// Compose dependencies
const clock = createClock();
const crypto = createCrypto();
const database = createDatabase("./app.db");
// Create composed service
const userService = {
createUser: createUser(clock, crypto, database),
};
// Use in HTTP handlers
const handleCreateUser = async (request: Request): Promise<Response> => {
const userData = await request.json();
const result = await userService.createUser(userData);
return result.ok
? Response.json(result.value, { status: 201 })
: Response.json({ error: result.error }, { status: 400 });
};
Pure Functions and Side Effects
Keep business logic pure and push side effects to application boundaries:
Pure Core
// ✅ CORRECT: Pure business logic
export const calculateOrderTotal = (items: OrderItem[]): number =>
items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
export const canUserAccessResource = (user: User, resource: Resource): boolean =>
user.role === "admin" || resource.ownerId === user.id;
export const validateEmail = (email: string): Result<string, ValidationError> => {
if (!email.includes("@")) {
return err({ type: "invalid_email", field: "email" });
}
return ok(email);
};
Effectful Edges
// ✅ CORRECT: Side effects at boundaries
export const createOrderHandler =
(logger: Logger, db: Database, emailService: EmailService) =>
async (request: Request): Promise<Response> => {
try {
// Parse input (side effect)
const orderData = await request.json();
// Pure validation and business logic
const validation = validateOrderData(orderData);
if (!validation.ok) {
return Response.json({ error: validation.error }, { status: 400 });
}
const order = createOrderFromData(validation.value);
const total = calculateOrderTotal(order.items);
// Side effects (database, email)
const saveResult = await db.saveOrder({ ...order, total });
if (!saveResult.ok) {
logger.error("Failed to save order", saveResult.error);
return Response.json({ error: "Internal error" }, { status: 500 });
}
await emailService.sendOrderConfirmation(order);
return Response.json(saveResult.value, { status: 201 });
} catch (error) {
logger.error("Unhandled error", error);
return Response.json({ error: "Internal error" }, { status: 500 });
}
};
Testing Made Simple
Light FP makes testing straightforward through pure functions and dependency injection:
Testing Pure Functions
import { assertEquals } from "https://deno.land/std/testing/asserts.ts";
Deno.test("calculateOrderTotal - calculates correct total", () => {
const items = [
{ price: 10, quantity: 2 },
{ price: 5, quantity: 3 },
];
const total = calculateOrderTotal(items);
assertEquals(total, 35);
});
Deno.test("validateEmail - rejects invalid email", () => {
const result = validateEmail("invalid-email");
assertEquals(result.ok, false);
if (!result.ok) {
assertEquals(result.error.type, "invalid_email");
}
});
Testing with Mock Ports
Deno.test("createUser - creates user with correct timestamp", async () => {
// Arrange: Mock dependencies
const fixedDate = new Date("2024-01-01T00:00:00Z");
const mockClock = {
now: () => fixedDate,
timestamp: () => fixedDate.getTime()
};
const mockCrypto = { randomUUID: () => "test-uuid-123" };
const mockDb = { save: async (user: User) => ok(user) };
// Act
const result = await createUser(mockClock, mockCrypto, mockDb)({
name: "Test User",
email: "test@example.com"
});
// Assert
assertEquals(result.ok, true);
if (result.ok) {
assertEquals(result.value.id, "test-uuid-123");
assertEquals(result.value.createdAt, fixedDate);
}
});
Practical Patterns and Utilities
Smart Constructors for Validated Types
export type Email = string & { readonly __brand: "Email" };
export type UserId = string & { readonly __brand: "UserId" };
export const createEmail = (input: string): Result<Email, ValidationError> => {
if (!input.includes("@")) {
return err({ type: "invalid_email", field: "email" });
}
return ok(input as Email);
};
Result Utility Functions
export const map = <T, U, E>(
result: Result<T, E>,
fn: (value: T) => U
): Result<U, E> =>
result.ok ? ok(fn(result.value)) : result;
export const flatMap = <T, U, E>(
result: Result<T, E>,
fn: (value: T) => Result<U, E>
): Result<U, E> =>
result.ok ? fn(result.value) : result;
Pipeline Composition
const processUserData = (rawData: unknown) =>
pipe(rawData)
.flatMap(parseUserData)
.flatMap(validateUserData)
.flatMap(enrichUserData)
.unwrap();
Migration Strategy
1. Start with New Code
Apply Light FP patterns to all new features and modules. This establishes the patterns without disrupting existing functionality.
2. Convert Data Models
Replace interface-based data models with readonly types:
// Before
interface User { id: string; name: string; }
// After
type User = { readonly id: string; readonly name: string; };
3. Extract Capabilities
Identify external dependencies and create port interfaces:
// Before: Direct dependency
async function createUser(userData: any) {
const user = { id: crypto.randomUUID(), ...userData };
await database.save(user);
return user;
}
// After: Injected dependencies
const createUser = (crypto: Crypto, db: Database) =>
async (userData: CreateUserData) => {
const user = { id: crypto.randomUUID(), ...userData };
return await db.save(user);
};
4. Replace Exceptions with Results
Gradually convert throwing functions to return Result types:
// Before
function parseConfig(input: string): Config {
if (!input) throw new Error("Config required");
return JSON.parse(input);
}
// After
function parseConfig(input: string): Result<Config, ConfigError> {
if (!input) return err({ type: "required", field: "config" });
try {
return ok(JSON.parse(input));
} catch {
return err({ type: "invalid_json", field: "config" });
}
}
Benefits in Practice
Teams adopting Light FP report:
- Easier testing: Pure functions and dependency injection simplify unit tests
- Better error handling: Explicit Result types prevent uncaught exceptions
- Clearer architecture: Separation between pure domain and effectful edges
- Improved maintainability: Immutable data reduces debugging complexity
- Team onboarding: Clear patterns make codebase easier to understand
Code Review Checklist
- Data defined with
type
andreadonly
properties - Capabilities defined with
interface
- Fallible operations return
Result<T, E>
- Dependencies injected through function parameters
- Pure functions for business logic
- Side effects pushed to application boundaries
Getting Started
Set up your tsconfig.json with strict settings:
{ "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true } }
Create your first port interface for a capability you use
Convert one data model from interface to readonly type
Replace one throwing function with a Result-returning function
Write tests for your pure functions
Light FP isn't about adopting exotic functional programming concepts - it's about applying practical patterns that make TypeScript code more reliable, testable, and maintainable.
Start small, be consistent, and watch your codebase become more robust over time.