Theme 07 · Effect / Rust / OCaml-Style Design
OCaml-ish domain modules
Explanation
OCaml-ish domain modules
Plain Human Explanation
OCaml code often puts a domain idea behind a module boundary. Callers can use the value, but they cannot freely build a fake one unless they go through the module’s constructors.
In TypeScript, the same habit is useful for values that carry product rules: paid plan names, valid coupon codes, verified email addresses, active account states, or allowed retry delays. The module becomes the front door. It says, “Here are the only supported ways to create and change this thing.”
For users, this means fewer weird states leak into the product. For maintainers, it means the rule has one obvious home instead of being reimplemented across routes, jobs, and components.
Technical Explanation
An OCaml-ish domain module groups the type, constructor, queries, and transitions for one concept. The representation can still be visible in TypeScript, but the convention is clear: other code should call the module functions instead of assembling the raw shape by hand.
This works best when the module is small and specific. It should protect one domain value, not become a service layer for the whole feature.
Why It Matters
- User impact: invalid states are rejected before they can create confusing behavior.
- Product behavior: one module owns the rule for what the value means.
- Risk: callers build raw objects that look valid but skipped a required rule.
- Decision point: use this when a value has real business rules and appears in more than one place.
The Core Move
Put the domain type and its allowed constructors in one small module, then make the rest of the code ask that module for valid values.
Small Example
OCaml-ish domain modules: Small Example
Bad TypeScript Example
type CouponCode = string;
export function applyCoupon(totalCents: number, code: CouponCode) {
if (code === "") return totalCents;
return Math.round(totalCents * 0.9);
}
type CouponCode = string;
export function applyCoupon(
totalCents: number,
code: CouponCode,
) {
if (code === "") return totalCents;
return Math.round(totalCents * 0.9);
}
Good TypeScript Example
type CouponCode = {
value: string;
};
type CouponCodeResult =
| { ok: true; code: CouponCode }
| { ok: false; error: "empty-code" | "invalid-format" };
const couponCodePattern = /^[A-Z0-9]{6,12}$/;
export const CouponCode = {
fromInput(input: string): CouponCodeResult {
const value = input.trim().toUpperCase();
if (value.length === 0) return { ok: false, error: "empty-code" };
if (!couponCodePattern.test(value)) return { ok: false, error: "invalid-format" };
return { ok: true, code: { value } };
},
value(code: CouponCode) {
return code.value;
},
};
export function applyCoupon(totalCents: number, code: CouponCode) {
return Math.round(totalCents * 0.9);
}
type CouponCode = {
value: string;
};
type CouponCodeResult =
| {
ok: true;
code: CouponCode;
}
| {
ok: false;
error:
| "empty-code"
| "invalid-format";
};
const couponCodePattern =
/^[A-Z0-9]{6,12}$/;
export const CouponCode = {
fromInput(
input: string,
): CouponCodeResult {
const value = input
.trim()
.toUpperCase();
if (value.length === 0)
return {
ok: false,
error: "empty-code",
};
if (!couponCodePattern.test(value))
return {
ok: false,
error: "invalid-format",
};
return {
ok: true,
code: {
value,
},
};
},
value(code: CouponCode) {
return code.value;
},
};
export function applyCoupon(
totalCents: number,
code: CouponCode,
) {
return Math.round(totalCents * 0.9);
}
What Changed
- The bad version treats any string as a coupon code.
- The good version gives coupon creation one local front door.
applyCouponreceives a trusted value instead of repeating validation rules.
Realistic Example
OCaml-ish domain modules: Realistic Example
This example uses support ticket priority. The risky product behavior is letting arbitrary strings decide escalation, which can over-notify the team or hide urgent customer problems.
Bad TypeScript Example
type Ticket = {
id: string;
priority: string;
customerPlan: string;
};
export function shouldPageOnCall(ticket: Ticket) {
return ticket.priority === "urgent" || ticket.customerPlan === "enterprise";
}
type Ticket = {
id: string;
priority: string;
customerPlan: string;
};
export function shouldPageOnCall(
ticket: Ticket,
) {
return (
ticket.priority === "urgent" ||
ticket.customerPlan === "enterprise"
);
}
Good TypeScript Example
type CustomerPlan = "free" | "pro" | "enterprise";
type TicketPriority = {
level: "normal" | "high" | "urgent";
};
type TicketPriorityInput = {
requestedLevel: string;
customerPlan: CustomerPlan;
};
export const TicketPriority = {
fromInput(input: TicketPriorityInput): TicketPriority {
if (input.customerPlan === "enterprise") return { level: "urgent" };
if (input.requestedLevel === "urgent") return { level: "urgent" };
if (input.requestedLevel === "high") return { level: "high" };
return { level: "normal" };
},
pagesOnCall(priority: TicketPriority) {
return priority.level === "urgent";
},
};
type Ticket = {
id: string;
priority: TicketPriority;
customerPlan: CustomerPlan;
};
export function shouldPageOnCall(ticket: Ticket) {
return TicketPriority.pagesOnCall(ticket.priority);
}
type CustomerPlan =
| "free"
| "pro"
| "enterprise";
type TicketPriority = {
level: "normal" | "high" | "urgent";
};
type TicketPriorityInput = {
requestedLevel: string;
customerPlan: CustomerPlan;
};
export const TicketPriority = {
fromInput(
input: TicketPriorityInput,
): TicketPriority {
if (input.customerPlan === "enterprise")
return {
level: "urgent",
};
if (input.requestedLevel === "urgent")
return {
level: "urgent",
};
if (input.requestedLevel === "high")
return {
level: "high",
};
return {
level: "normal",
};
},
pagesOnCall(priority: TicketPriority) {
return priority.level === "urgent";
},
};
type Ticket = {
id: string;
priority: TicketPriority;
customerPlan: CustomerPlan;
};
export function shouldPageOnCall(
ticket: Ticket,
) {
return TicketPriority.pagesOnCall(
ticket.priority,
);
}
What Changed
- The bad version spreads priority meaning across loose strings.
- The good version puts escalation rules inside the
TicketPrioritymodule. - Other code can ask a clear question instead of reinterpreting priority values.
System Example
OCaml-ish domain modules: System Example
At system scale, an OCaml-ish module gives routes, workers, and billing jobs one shared interpretation of a domain value without creating a broad service object.
Larger System-Level Bad TypeScript Example
type SubscriptionRow = {
userId: string;
plan: string;
seats: number;
status: string;
};
export function canInviteMember(row: SubscriptionRow) {
if (row.status !== "active") return false;
if (row.plan === "team" && row.seats > 1) return true;
if (row.plan === "enterprise") return true;
return false;
}
export function monthlyPrice(row: SubscriptionRow) {
if (row.plan === "team") return row.seats * 2000;
if (row.plan === "enterprise") return row.seats * 5000;
return 0;
}
type SubscriptionRow = {
userId: string;
plan: string;
seats: number;
status: string;
};
export function canInviteMember(
row: SubscriptionRow,
) {
if (row.status !== "active") return false;
if (row.plan === "team" && row.seats > 1)
return true;
if (row.plan === "enterprise")
return true;
return false;
}
export function monthlyPrice(
row: SubscriptionRow,
) {
if (row.plan === "team")
return row.seats * 2000;
if (row.plan === "enterprise")
return row.seats * 5000;
return 0;
}
Larger System-Level Good TypeScript Example
type PlanKind = "free" | "team" | "enterprise";
type SubscriptionPlan = {
kind: PlanKind;
includedSeats: number;
pricePerSeatCents: number;
};
type PlanResult =
| { ok: true; plan: SubscriptionPlan }
| { ok: false; error: "unknown-plan" | "invalid-seat-count" };
export const SubscriptionPlan = {
fromBillingRow(planName: string, seats: number): PlanResult {
if (!Number.isInteger(seats) || seats < 1) {
return { ok: false, error: "invalid-seat-count" };
}
if (planName === "free") {
return { ok: true, plan: { kind: "free", includedSeats: 1, pricePerSeatCents: 0 } };
}
if (planName === "team") {
return { ok: true, plan: { kind: "team", includedSeats: seats, pricePerSeatCents: 2000 } };
}
if (planName === "enterprise") {
return { ok: true, plan: { kind: "enterprise", includedSeats: seats, pricePerSeatCents: 5000 } };
}
return { ok: false, error: "unknown-plan" };
},
canInviteMember(plan: SubscriptionPlan, status: "active" | "past_due" | "canceled") {
return status === "active" && plan.includedSeats > 1;
},
monthlyPrice(plan: SubscriptionPlan) {
return plan.includedSeats * plan.pricePerSeatCents;
},
};
type PlanKind =
| "free"
| "team"
| "enterprise";
type SubscriptionPlan = {
kind: PlanKind;
includedSeats: number;
pricePerSeatCents: number;
};
type PlanResult =
| {
ok: true;
plan: SubscriptionPlan;
}
| {
ok: false;
error:
| "unknown-plan"
| "invalid-seat-count";
};
export const SubscriptionPlan = {
fromBillingRow(
planName: string,
seats: number,
): PlanResult {
if (
!Number.isInteger(seats) ||
seats < 1
) {
return {
ok: false,
error: "invalid-seat-count",
};
}
if (planName === "free") {
return {
ok: true,
plan: {
kind: "free",
includedSeats: 1,
pricePerSeatCents: 0,
},
};
}
if (planName === "team") {
return {
ok: true,
plan: {
kind: "team",
includedSeats: seats,
pricePerSeatCents: 2000,
},
};
}
if (planName === "enterprise") {
return {
ok: true,
plan: {
kind: "enterprise",
includedSeats: seats,
pricePerSeatCents: 5000,
},
};
}
return {
ok: false,
error: "unknown-plan",
};
},
canInviteMember(
plan: SubscriptionPlan,
status:
| "active"
| "past_due"
| "canceled",
) {
return (
status === "active" &&
plan.includedSeats > 1
);
},
monthlyPrice(plan: SubscriptionPlan) {
return (
plan.includedSeats *
plan.pricePerSeatCents
);
},
};
What Changed
- The bad version makes every caller remember what plan strings and seat counts mean.
- The good version makes
SubscriptionPlanthe owner of plan construction and plan questions. - Billing, invite, and admin code can share the same domain interpretation without sharing a giant service.
When To Use It
OCaml-ish domain modules: When To Use It
Use This When
- A value has rules that callers keep reimplementing.
- The same concept appears in routes, jobs, tests, and UI code with slightly different assumptions.
- You want one small module to own construction, validation, and basic questions for a domain value.
Avoid This When
- The value is just data with no product rule.
- The module would become a grab bag for unrelated feature behavior.
- TypeScript cannot truly hide the representation and the team will not respect the constructor convention.
Tradeoffs
This pattern adds a little naming ceremony. The payoff is a clear owner for important rules. Keep the module tiny: type, constructors, transitions, and simple queries.
Related Concepts
- Rust-like safety comments
- Tagged unions
- Errors-as-values thinking
Practice Prompt
OCaml-ish domain modules: Practice Prompt
Beginner Exercise
Pick one loose domain value, such as a plan name, role, status, coupon code, or retry delay. List the rules that make it valid.
Intermediate Exercise
Create a small module for that value with one constructor and one query function. Update one caller to use the constructor result.
Stretch Exercise
Find duplicate validation for the same value in two places. Move only the shared value rule into the module, leaving feature-specific behavior where it belongs.
Reflection Question
Does the module protect one clear concept, or is it starting to collect unrelated feature logic?
Suggest an edit
Leave a private editorial note. This creates a GitHub issue for this curriculum page.