02 Refined values 6 chapters

Theme 02 · Domain-Driven / Functional-Core Minded

Refined values

Explanation

Refined values

Plain Human Explanation

A refined value is a value that has already passed an important check. After an email is validated, the rest of the app should not keep asking whether it is really an email. The type should carry that trust forward.

Technical Explanation

Refined values are created by constructors or parsers that validate raw input and return a narrower type. That type might be a branded primitive, a small object, or a union result. Core code accepts the refined type, which keeps validation at boundaries and prevents repeated partial checks throughout the system.

Why It Matters

  • User impact: invalid values are rejected early and consistently.
  • Product behavior: rules such as positive money, non-empty names, and valid email addresses stay attached to the values they protect.
  • Risk: repeated inline checks drift or get skipped in less-traveled code paths.
  • Decision point: use refined values when one validation rule matters across several operations.

The Core Move

Turn raw data into trusted domain values once. Pass the trusted value inward instead of passing raw strings and numbers everywhere.

Small Example

Refined values: Small Example

Bad TypeScript Example

export function sendReceipt(email: string) {
  if (!email.includes("@")) {
    throw new Error("Invalid email");
  }

  return { to: email.toLowerCase(), subject: "Your receipt" };
}
export function sendReceipt(email: string) {
  if (!email.includes("@")) {
    throw new Error("Invalid email");
  }

  return {
    to: email.toLowerCase(),
    subject: "Your receipt",
  };
}

Good TypeScript Example

export type EmailAddress = string & {
  readonly __brand: "EmailAddress";
};

export function emailAddress(value: string): EmailAddress | null {
  const normalized = value.trim().toLowerCase();
  if (!normalized.includes("@")) return null;
  return normalized as EmailAddress;
}

export function sendReceipt(email: EmailAddress) {
  return { to: email, subject: "Your receipt" };
}

const email = emailAddress("Andy@example.com");
if (email) sendReceipt(email);
export type EmailAddress = string & {
  readonly __brand: "EmailAddress";
};

export function emailAddress(
  value: string,
): EmailAddress | null {
  const normalized = value
    .trim()
    .toLowerCase();

  if (!normalized.includes("@"))
    return null;

  return normalized as EmailAddress;
}

export function sendReceipt(
  email: EmailAddress,
) {
  return {
    to: email,
    subject: "Your receipt",
  };
}

const email = emailAddress(
  "Andy@example.com",
);
if (email) sendReceipt(email);

What Changed

  • The bad version makes every caller rely on sendReceipt to validate raw strings.
  • The good version exports one approved way to create a trusted email value.
  • sendReceipt can focus on receipt behavior because invalid emails cannot reach it without a bad cast.

Realistic Example

Refined values: Realistic Example

This example uses money values, where negative or fractional cents should not move through billing logic.

Bad TypeScript Example

export function applyCredit(balanceCents: number, creditCents: number) {
  if (creditCents < 0) throw new Error("Credit must be positive");
  return balanceCents - creditCents;
}

applyCredit(1200, -500);
export function applyCredit(
  balanceCents: number,
  creditCents: number,
) {
  if (creditCents < 0)
    throw new Error(
      "Credit must be positive",
    );

  return balanceCents - creditCents;
}

applyCredit(1200, -500);

Good TypeScript Example

type NonNegativeCents = number & {
  readonly __brand: "NonNegativeCents";
};
type PositiveCents = number & {
  readonly __brand: "PositiveCents";
};

function nonNegativeCents(value: number): NonNegativeCents | null {
  if (!Number.isInteger(value) || value < 0) return null;
  return value as NonNegativeCents;
}

function positiveCents(value: number): PositiveCents | null {
  if (!Number.isInteger(value) || value <= 0) return null;
  return value as PositiveCents;
}

export function applyCredit(balanceCents: NonNegativeCents, creditCents: PositiveCents): NonNegativeCents {
  return Math.max(0, balanceCents - creditCents) as NonNegativeCents;
}

const balance = nonNegativeCents(1200);
const credit = positiveCents(500);
if (balance && credit) applyCredit(balance, credit);
type NonNegativeCents = number & {
  readonly __brand: "NonNegativeCents";
};
type PositiveCents = number & {
  readonly __brand: "PositiveCents";
};

function nonNegativeCents(
  value: number,
): NonNegativeCents | null {
  if (!Number.isInteger(value) || value < 0)
    return null;

  return value as NonNegativeCents;
}

function positiveCents(
  value: number,
): PositiveCents | null {
  if (
    !Number.isInteger(value) ||
    value <= 0
  )
    return null;

  return value as PositiveCents;
}

export function applyCredit(
  balanceCents: NonNegativeCents,
  creditCents: PositiveCents,
): NonNegativeCents {
  return Math.max(
    0,
    balanceCents - creditCents,
  ) as NonNegativeCents;
}

const balance = nonNegativeCents(1200);
const credit = positiveCents(500);
if (balance && credit)
  applyCredit(balance, credit);

What Changed

  • The bad version accepts any number and tries to reject bad ones inside billing logic.
  • The good version makes both balance and credit amounts named trusted values.
  • Other billing functions can reuse the same refined value instead of writing their own money checks.

System Example

Refined values: System Example

At system scale, refined values keep API handlers, jobs, and stores from each inventing their own validation rule.

Larger System-Level Bad TypeScript Example

type InvitePorts = {
  teams: {
    invite(teamName: string, email: string): Promise<void>;
  };
  email: {
    send(to: string, subject: string): Promise<void>;
  };
};

export async function inviteUser(
  req: {
    body: {
      email: string;
      teamName: string;
    };
  },
  ports: InvitePorts,
) {
  if (!req.body.email.includes("@")) return { status: 400 };
  if (!req.body.teamName.trim()) return { status: 400 };

  await ports.teams.invite(req.body.teamName.trim(), req.body.email.toLowerCase());
  await ports.email.send(req.body.email, "You were invited");
  return { status: 202 };
}
type InvitePorts = {
  teams: {
    invite(
      teamName: string,
      email: string,
    ): Promise<void>;
  };
  email: {
    send(
      to: string,
      subject: string,
    ): Promise<void>;
  };
};

export async function inviteUser(
  req: {
    body: {
      email: string;
      teamName: string;
    };
  },
  ports: InvitePorts,
) {
  if (!req.body.email.includes("@"))
    return {
      status: 400,
    };

  if (!req.body.teamName.trim())
    return {
      status: 400,
    };

  await ports.teams.invite(
    req.body.teamName.trim(),
    req.body.email.toLowerCase(),
  );

  await ports.email.send(
    req.body.email,
    "You were invited",
  );

  return {
    status: 202,
  };
}

Larger System-Level Good TypeScript Example

type EmailAddress = string & {
  readonly __brand: "EmailAddress";
};
type NonEmptyName = string & {
  readonly __brand: "NonEmptyName";
};

type InviteUser = {
  email: EmailAddress;
  teamName: NonEmptyName;
};

type InvitePorts = {
  teams: {
    invite(teamName: NonEmptyName, email: EmailAddress): Promise<void>;
  };
  email: {
    send(to: EmailAddress, subject: string): Promise<void>;
  };
};

function parseInviteUser(body: Record<string, unknown>): InviteUser | null {
  const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : "";
  const teamName = typeof body.teamName === "string" ? body.teamName.trim() : "";

  if (!email.includes("@")) return null;
  if (!teamName) return null;

  return { email: email as EmailAddress, teamName: teamName as NonEmptyName };
}

export async function inviteUser(
  req: {
    body: Record<string, unknown>;
  },
  ports: InvitePorts,
) {
  const input = parseInviteUser(req.body);
  if (!input) return { status: 400 };

  await ports.teams.invite(input.teamName, input.email);
  await ports.email.send(input.email, "You were invited");
  return { status: 202 };
}
type EmailAddress = string & {
  readonly __brand: "EmailAddress";
};
type NonEmptyName = string & {
  readonly __brand: "NonEmptyName";
};

type InviteUser = {
  email: EmailAddress;
  teamName: NonEmptyName;
};

type InvitePorts = {
  teams: {
    invite(
      teamName: NonEmptyName,
      email: EmailAddress,
    ): Promise<void>;
  };
  email: {
    send(
      to: EmailAddress,
      subject: string,
    ): Promise<void>;
  };
};

function parseInviteUser(
  body: Record<string, unknown>,
): InviteUser | null {
  const email =
    typeof body.email === "string"
      ? body.email.trim().toLowerCase()
      : "";

  const teamName =
    typeof body.teamName === "string"
      ? body.teamName.trim()
      : "";

  if (!email.includes("@")) return null;

  if (!teamName) return null;

  return {
    email: email as EmailAddress,
    teamName: teamName as NonEmptyName,
  };
}

export async function inviteUser(
  req: {
    body: Record<string, unknown>;
  },
  ports: InvitePorts,
) {
  const input = parseInviteUser(req.body);

  if (!input)
    return {
      status: 400,
    };

  await ports.teams.invite(
    input.teamName,
    input.email,
  );

  await ports.email.send(
    input.email,
    "You were invited",
  );

  return {
    status: 202,
  };
}

What Changed

  • The bad version normalizes and validates fields inline, then still passes raw strings around.
  • The good version creates a single trusted invite command.
  • Both team storage and email sending receive values that already passed the domain checks.

When To Use It

Refined values: When To Use It

Use This When

  • A validation rule matters in more than one place.
  • Raw values are easy to misuse, such as emails, ids, money, percentages, slugs, and non-empty names.
  • You want core functions to accept already-trusted values.

Avoid This When

  • The check is only relevant to one local branch and will not be reused.
  • The refined type would require frequent unsafe casts because parsing is not at the boundary.
  • The rule is too vague to name clearly.

Tradeoffs

Refined values add constructors or parsers. The benefit is that validation becomes explicit and reusable. The danger is pretending a cast is validation; a refined value only means something if it is created through the approved path.

  • Branded types
  • Parsed inputs
  • Domain modules

Practice Prompt

Refined values: Practice Prompt

Beginner Exercise

Find a string or number that is checked in more than one place. Name the trusted value it should become.

Intermediate Exercise

Write a constructor or parser for that value and update one core function so it accepts only the refined type.

Stretch Exercise

Move validation to the boundary and remove one duplicate inline check from deeper product logic.

Reflection Question

What makes this value safe after construction, and how will future callers know not to cast around it?

Suggest an edit

Leave a private editorial note. This creates a GitHub issue for this curriculum page.