In this fourth and final episode of our core TypeScript masterclass series, we explore Functional Programming & The Effect-TS Prelude. We cover pure functions, Algebraic Data Types (ADTs), building Option and Either monadic types, typed error handling without throw, function composition (pipe and flow), schema validation, and the foundational concepts of Effect-TS (Effect<A, E, R>).
1. The Core Primitives of Functional TypeScript#
Functional Programming (FP) rests on three mathematical pillars:
- Pure Functions: Functions that produce identical outputs for identical inputs without mutating external state or causing side effects.
- Referential Transparency: An expression can be replaced by its evaluated value without changing program behavior.
- Immutability: Data structures cannot be modified after creation; new states are produced via transformations.
// Impure Function (Mutates external state & relies on I/O)
let globalTotal = 0;
function addToTotalImpure(amount: number): number {
globalTotal += amount;
return globalTotal;
}
// Pure Function (No side effects, immutable output)
function addToTotalPure(currentTotal: number, amount: number): number {
return currentTotal + amount;
}2. Eliminating null and undefined with Option<T>#
In standard TypeScript, functions returning T | null force verbose null-checking. The Option<T> type models optional values as an Algebraic Data Type consisting of Some<T> or None:
// Algebraic Data Type Definition
export type Some<A> = { readonly _tag: "Some"; readonly value: A };
export type None = { readonly _tag: "None" };
export type Option<A> = Some<A> | None;
// Constructors
export const some = <A>(value: A): Option<A> => ({ _tag: "Some", value });
export const none: Option<never> = { _tag: "None" };
// Helper Utilities
export function isSome<A>(opt: Option<A>): opt is Some<A> {
return opt._tag === "Some";
}
export function mapOption<A, B>(opt: Option<A>, fn: (a: A) => B): Option<B> {
return isSome(opt) ? some(fn(opt.value)) : none;
}
// Real-World Usage
function findUserById(id: string): Option<{ id: string; name: string }> {
if (id === "usr_101") {
return some({ id: "usr_101", name: "Rachmat Hidayat" });
}
return none;
}
const userOpt = findUserById("usr_101");
const usernameOpt = mapOption(userOpt, (u) => u.name.toUpperCase());
if (isSome(usernameOpt)) {
console.log(`Found: ${usernameOpt.value}`); // "Found: RACHMAT HIDAYAT"
}3. Type-Driven Error Handling with Either<E, A>#
In standard TypeScript, throw new Error() is invisible to the compiler—return types do not declare which errors a function can throw.
The Either<E, A> type replaces throw with typed return values: Left<E> represents failure, and Right<A> represents success.
flowchart TD
FunctionCall["Execute Function: parseJSON(raw)"] --> Result{"Return Either"}
Result -->|Failure| LeftBranch["Left(ParseError): Fully Typed Error"]
Result -->|Success| RightBranch["Right(Data): Valid Parsed Result"]
export type Left<E> = { readonly _tag: "Left"; readonly left: E };
export type Right<A> = { readonly _tag: "Right"; readonly right: A };
export type Either<E, A> = Left<E> | Right<A>;
export const left = <E, A = never>(e: E): Either<E, A> => ({ _tag: "Left", left: e });
export const right = <A, E = never>(a: A): Either<E, A> => ({ _tag: "Right", right: a });
// Domain Errors
type ValidationError = { _tag: "ValidationError"; message: string };
type NetworkError = { _tag: "NetworkError"; statusCode: number };
type ApplicationError = ValidationError | NetworkError;
// Function signature explicitly declares possible failure types!
function validateAge(age: number): Either<ValidationError, number> {
if (age < 18) {
return left({ _tag: "ValidationError", message: "Must be 18 or older." });
}
return right(age);
}
const result = validateAge(15);
if (result._tag === "Left") {
console.error(`Validation Failed: ${result.left.message}`);
} else {
console.log(`Valid Age: ${result.right}`);
}4. Function Composition with pipe#
Function piping passes the output of one pure function as the input to the next, building clean readable data transformation pipelines:
export function pipe<A>(a: A): A;
export function pipe<A, B>(a: A, ab: (a: A) => B): B;
export function pipe<A, B, C>(a: A, ab: (a: A) => B, bc: (b: B) => C): C;
export function pipe(initial: any, ...fns: Function[]): any {
return fns.reduce((acc, fn) => fn(acc), initial);
}
const trim = (s: string) => s.trim();
const toLower = (s: string) => s.toLowerCase();
const addPrefix = (s: string) => `user_${s}`;
// Pipeline execution
const formattedUsername = pipe(
" RACHMAT_HIDAYAT ",
trim,
toLower,
addPrefix
);
console.log(formattedUsername); // "user_rachmat_hidayat"
5. Schema Validation Prelude (Zod & Effect.Schema)#
Validate untrusted external data (API JSON, environment variables) at runtime while automatically producing static TypeScript types:
import { z } from "zod";
// Define Runtime Schema
export const UserConfigSchema = z.object({
apiEndpoint: z.string().url(),
timeoutMs: z.number().min(100).max(10000).default(3000),
environment: z.enum(["development", "staging", "production"]),
});
// Infer Static TypeScript Type automatically!
export type UserConfig = z.infer<typeof UserConfigSchema>;
function loadConfig(rawInput: unknown): UserConfig {
// Parses & validates at runtime, throwing typed ZodError if invalid
return UserConfigSchema.parse(rawInput);
}6. Bridge to Effect-TS: The Future of Production TypeScript#
While primitives like Option and Either elevate code quality, enterprise applications require concurrency, Fiber management, resource cleanup, and dependency injection.
This is where Effect-TS shines. At its core, Effect models computations using the generic type:
$$\text{Effect}<\text{Value}, \text{Error}, \text{Requirements}> \quad \implies \quad \text{Effect}<A, E, R>$$
import { Effect, Console } from "effect";
// Effect<Value, Error, Requirements>
// Represents a computation that produces string, fails with Error, and requires no context (never)
const program: Effect.Effect<string, Error, never> = Effect.gen(function* (_) {
yield* _(Console.log("Starting Effect computation..."));
const randomNumber = yield* _(Effect.succeed(42));
if (randomNumber < 10) {
yield* _(Effect.fail(new Error("Number too low!")));
}
return `Success result: ${randomNumber}`;
});
// Run the Effect program
Effect.runPromise(program).then(console.log);This functional prelude establishes the foundational type patterns required for high-scale TypeScript systems. Stay tuned for our dedicated Effect-TS Masterclass Series covering Fiber concurrency, Layer dependency injection, and Schema validation in full depth!
Key Takeaways#
- Eliminate Thrown Exceptions: Use
Either<E, A>to make error return types explicit and checked at compile time. - Replace Nulls with
Option<T>: Represent optional or missing data explicitly usingSomeandNonealgebraic data types. - Pipe Transformations: Use
pipeto build readable, composable data pipelines out of pure functions. - Validate Schema at Boundaries: Use Zod or
Effect.Schemato parse external API inputs and generate dynamic TypeScript interfaces automatically. - Prepare for Effect-TS: Embrace
Effect<A, E, R>to manage async side effects, fiber concurrency, and dependency layers cleanly.

