02 OCaml-ish domain modules 6 chapters

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.
  • applyCoupon receives 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 TicketPriority module.
  • 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 SubscriptionPlan the 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.

  • 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.