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
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'