02: TypeScript: pure functions, immutability, array methods

These mirror the frontend's business_logic/domain/expiry.ts and overlay.ts, its HTTP request mapping, and Personalization's verdict rules. The theme is pure functions that take plain values and return plain values**: the domain-logic style this repo favours.


2.1: A pure function with a narrow signature

Concept. A pure function depends only on its arguments and returns a value with no side effects (no I/O, no mutation of inputs, no clock unless passed in). Easy to test, easy to move around layers.

Example.

// Pure illustrative version: the caller supplies the clock.
export function daysUntilExpiry(expiry: string, now: Date): number {
  const expiryTime = Date.parse(expiry);
  if (Number.isNaN(expiryTime)) throw new Error('invalid expiry date');
  return Math.ceil((expiryTime - now.getTime()) / (1000 * 60 * 60 * 24));
}

export function computeFreshnessStatus(
  expiry: string,
  now: Date,
): 'fresh' | 'expiring' | 'expired' {
  const days = daysUntilExpiry(expiry, now);
  if (days <= 0) return 'expired';
  if (days <= 5) return 'expiring';
  return 'fresh';
}

Your exercise. Write a pure function band(value: number, high: number, low: number): 'poor' | 'neutral' | 'good' that returns 'poor' when 'value >= high', 'good' when 'value <= low', and 'neutral' otherwise. (This mirrors the band helper from verdict/rules.ts.)

Solution
function band(value: number, high: number, low: number): 'poor' | 'neutral' | 'good' {
  if (value >= high) return 'poor';
  if (value <= low) return 'good';
  return 'neutral';
}

2.2: Type guards: narrowing with x is T

Concept. A function returning x is T is a type guard: when it returns true, the compiler narrows the argument to T for the rest of the scope. Lets you handle "optional/maybe-invalid" values safely.

Example.

// From domain/expiry.ts. After this returns true, `expiry` is a `string`, not `string | null | undefined`.
export function hasValidExpiry(expiry: string | null | undefined): expiry is string {
  if (!expiry) return false;
  return !Number.isNaN(new Date(expiry).getTime());
}

function show(expiry?: string) {
  if (hasValidExpiry(expiry)) {
    return new Date(expiry).toLocaleDateString(); // expiry: string here, no ?. needed
  }
  return null;
}

Your exercise. Write a guard isNonEmpty(s: string | null | undefined): s is string that returns true only for a non-empty, non-whitespace string. Use it in a function that returns s.trim().toUpperCase() only when the guard passes.

Solution
function isNonEmpty(s: string | null | undefined): s is string {
  return !!s && s.trim().length > 0;
}

function shout(s?: string): string | null {
  return isNonEmpty(s) ? s.trim().toUpperCase() : null;
}

2.3: Immutability with the spread operator

Concept. { ...obj, field: newValue } builds a new object copying obj and overriding field. The original is never mutated. This is how the domain "updates" data without side effects.

Example.

// From domain/overlay.ts: overlay physical-item facts onto a product, returning a NEW product.
interface Product { batch: string; serial: string; expiry?: string; }
interface Facts { expiry?: string; lot?: string; serial?: string; }

function overlayProduct(product: Product, facts: Facts): Product {
  return {
    ...product,
    ...(facts.expiry ? { expiry: facts.expiry } : {}), // conditional spread: include key only if present
    ...(facts.lot ? { batch: facts.lot } : {}),
    ...(facts.serial ? { serial: facts.serial } : {}),
  };
}

The ...(cond ? { k: v } : {}) trick: spread an object with the key when the condition holds, or spread nothing ({}) when it doesn't. That's how you build a body that omits absent optional fields rather than setting them to undefined.

Your exercise. Write scanRequestBody(gtin: string, params: { lot?: string; serial?: string; expiry?: string }): object that always includes gtin and includes lot/serial/expiry only when present. (This is http/request.ts.)

Solution
function scanRequestBody(gtin: string, params: { lot?: string; serial?: string; expiry?: string }) {
  return {
    gtin,
    ...(params.lot ? { lot: params.lot } : {}),
    ...(params.serial ? { serial: params.serial } : {}),
    ...(params.expiry ? { expiry: params.expiry } : {}),
  };
}
*scanRequestBody('123', {})* -> *{ gtin: '123' }*, with no *lot: undefined* keys.

2.4: map, filter, some, includes

Concept. Prefer declarative array methods over manual loops. filter keeps elements matching a predicate; map transforms each; some asks "does any match?"; includes asks "is this value present?".

Example.

// From verdict/rules.ts: allergen screening: keep profile allergies that appear on the label.
interface Profile { allergies: string[]; }
interface Label { allergens: string[]; }
interface Reason { type: string; matchedCode: string; severity: string; }

function allergenConflicts(profile: Profile, label: Label): Reason[] {
  return profile.allergies
    .filter((code) => label.allergens.includes(code)) // keep only allergies present on the label
    .map((code) => ({ type: 'allergen', matchedCode: code, severity: 'high' })); // shape each into a Reason
}

Your exercise. Given checks: { code: string; threshold: number }[] and a 'nutrition: Record', write firedCodes(checks, nutrition): string[] returning the codes whose threshold is '<=' the matching nutrition[code] (treat a missing nutrition value as 0). Use filter + map, and ?? for the missing-value default.

Solution
function firedCodes(
  checks: { code: string; threshold: number }[],
  nutrition: Record<string, number>,
): string[] {
  return checks
    .filter((c) => (nutrition[c.code] ?? 0) >= c.threshold)
    .map((c) => c.code);
}
*(nutrition[c.code] ?? 0)* mirrors the current *checkFires* rule, where a missing nutrient reads as 0 (boundary-inclusive '>=').

2.5: ?. and ??: optional chaining and nullish coalescing

Concept. a?.b reads b only if a is non-null, else short-circuits to undefined. x ?? y yields x unless it is null/undefined, then y. Note: ?? differs from ||: 0 ?? 5 is 0, but 0 || 5 is 5.

Example.

// From verdict/rules.ts: optional-chained `.some` plus nullish default.
interface Check { allergensAnyOf?: string[]; }
function anyAllergen(check: Check, labelAllergens: string[]): boolean {
  return check.allergensAnyOf?.some((c) => labelAllergens.includes(c)) ?? false;
  //                          ^ if allergensAnyOf is undefined, the whole .some(...) is undefined,
  //                            so ?? false gives a clean boolean
}

Your exercise. Write displayName(account: { displayName?: string; email: string }): string returning the displayName if present, else the email. Then explain why account.displayName ?? account.email is correct but account.displayName || account.email could also fall through on an empty-string display name, and whether that's what you want.

Solution
function displayName(account: { displayName?: string; email: string }): string {
  return account.displayName ?? account.email;
}
*??* only falls through on *null*/*undefined*. *\|\|* also falls through on *''* (an empty string is falsy). If you treat an empty display name as "no name", *\|\|* is what you want; if an empty string is a legitimate (if odd) value you must preserve, *??* is correct. Knowing which is the whole point of the distinction.

2.6: readonly and as const

Concept. readonly on a field prevents reassignment through the checked TypeScript type. as const narrows a literal and makes its inferred properties readonly (for example, an array becomes a readonly tuple of literals). Neither mechanism freezes an object at runtime; use a runtime mechanism such as Object.freeze when that guarantee is required.

Example.

const SEVERITIES = ['high', 'medium', 'low'] as const;
// type: readonly ['high', 'medium', 'low']
type Severity = (typeof SEVERITIES)[number]; // 'high' | 'medium' | 'low': derived, not hand-written

typeof SEVERITIES gets the value's type; [number] indexes into it to get the element union. One source of truth: the array drives the type.

Your exercise. Build a const GRADES = [...] as const for the four verdict grades and derive type Grade = ... from it so that adding a grade to the array updates the type automatically.

Solution
const GRADES = ['Good', 'Careful', 'Avoid', 'Unknown'] as const;
type Grade = (typeof GRADES)[number]; // 'Good' | 'Careful' | 'Avoid' | 'Unknown'