01: TypeScript: types, interfaces, unions, generics¶
These mirror client/web-app/src/lib/business_logic/domain/types.ts, where the whole client's vocabulary of data shapes lives. The point of this file: be able to read and write a precise type from a verbal description of the data.
This exercise concerns the frontend's business_logic and infrastructure organization. The three TypeScript services use the different domain/application/adapters layout.
1.1: interface: the shape of an object¶
Concept. An interface names the shape of an object: which fields exist and their types. It is compile-time only: it disappears at runtime.
Example.
// A lightweight product summary (modelled on ProductSummary in types.ts).
interface ProductSummary {
gtin: string;
name: string;
brand?: string; // the ? makes this property OPTIONAL: it may be absent
image?: string;
}
const milk: ProductSummary = { gtin: '07310070017284', name: 'Oat Milk' };
// ^ brand/image omitted: legal because optional
Your exercise. Write an interface Recipe with a required id, required title, required sourceUrl, and optional imageUrl (all strings). Then declare a value with imageUrl omitted.
Solution
interface Recipe {
id: string;
title: string;
sourceUrl: string;
imageUrl?: string;
}
const recipe: Recipe = {
id: 'recipe-1',
title: 'Vegetable soup',
sourceUrl: 'https://example.test/recipes/vegetable-soup',
};
This mirrors the optional-image pattern used by product and recipe presentation types.
1.2: Union types and string-literal unions¶
Concept. A | B means "either A or B". When the members are string literals ('fresh' | 'expiring'), you get an enum-like type where only those exact strings are allowed: the compiler rejects typos.
Example.
// From FridgeItem.freshness in types.ts.
type Freshness = 'fresh' | 'expiring' | 'expired';
let f: Freshness = 'fresh'; // ok
// f = 'frsh'; // ❌ compile error: not one of the three literals
Your exercise. Define a type Severity allowing only 'high', 'medium', or 'low' (this is Alert.severity in the repository code). Then write a function isUrgent(s: Severity): boolean that returns true only for 'high'.
Solution
type Severity = 'high' | 'medium' | 'low';
function isUrgent(s: Severity): boolean {
return s === 'high';
}
1.3: Discriminated unions (the "tagged" union)¶
Concept. Give each variant of a union a shared literal field (the discriminant). Then a switch/if on that field tells the compiler exactly which variant you have, and which other fields are available. This is how you model "one of several outcomes, each carrying different data".
Example.
// From AddFridgeItemResult in types.ts.
interface FridgeItem { itemId: string; gtin: string; }
type AddResult =
| { outcome: 'added'; item: FridgeItem }
| { outcome: 'already' };
function describe(r: AddResult): string {
switch (r.outcome) {
case 'added':
return `added ${r.item.gtin}`; // r.item is known to exist here
case 'already':
return 'was already present'; // r.item does NOT exist here, and TS knows it
}
}
Your exercise. Model a verdict outcome as a discriminated union VerdictResult with two variants: - { status: 'computed'; grade: string } - { status: 'unavailable'; reason: string }
Then write render(v: VerdictResult): string that returns the grade when computed and the reason otherwise. Try to access v.grade in the 'unavailable' branch and notice the compiler stop you.
Solution
type VerdictResult =
| { status: 'computed'; grade: string }
| { status: 'unavailable'; reason: string };
function render(v: VerdictResult): string {
switch (v.status) {
case 'computed':
return v.grade;
case 'unavailable':
return v.reason;
// v.grade here would be: Property 'grade' does not exist on type '{ status: "unavailable"; ... }'
}
}
This is the current shape behind *ScanResponse.verdict* + *VerdictStatus* in *types.ts*:
the absence cases are kept distinguishable so the UI can show the right empty state.
1.4: extends: building a type on top of another¶
Concept. An interface can extend another, inheriting its fields and adding more. Models "X is a Y plus extra".
Example.
// From PhysicalItemFacts -> ScanParams in types.ts.
interface PhysicalItemFacts {
expiry?: string;
lot?: string;
serial?: string;
}
interface ScanParams extends PhysicalItemFacts {
gtin?: string;
id?: string;
}
// ScanParams now has expiry, lot, serial, gtin, id: all optional.
Your exercise. Given interface Account { id: string; email: string }, write interface AccountWithName extends Account that adds a required displayName: string.
Solution
interface Account { id: string; email: string; }
interface AccountWithName extends Account {
displayName: string;
}
1.5: Indexed access types: Product['nutrition']¶
Concept. T['key'] extracts the type of a property from another type. Lets you reuse a nested shape without re-typing it (and stay in sync if it changes).
Example.
// From ScanResponse in types.ts: the digitalLabel reuses Product's nutrition type.
interface Product {
name: string;
nutrition: { energy: string; sugar: string; salt: string };
}
interface DigitalLabel {
ingredients: string;
nutrition: Product['nutrition']; // exactly the same shape as Product.nutrition
}
Your exercise. Given the Product above, write a function saltOf(p: Product): Product['nutrition']['salt'] returning p.nutrition.salt. What concrete type is the return value?
Solution
function saltOf(p: Product): Product['nutrition']['salt'] {
return p.nutrition.salt;
}
// Product['nutrition']['salt'] resolves to `string`.
1.6: Generics: a type that takes a type¶
Concept. A generic '
Example.
// A generic "list response" wrapper.
interface Page<T> {
items: T[];
total: number;
}
const products: Page<{ gtin: string; name: string }> = {
items: [{ gtin: '07310070017284', name: 'Oat Milk' }],
total: 1,
};
Your exercise. Write a generic function 'first
Solution
function first<T>(xs: T[]): T | undefined {
return xs[0];
}
const a = first([1, 2, 3]); // a: number | undefined
const b = first(['x']); // b: string | undefined
You write the logic once; the caller's array type flows through *T*.
1.7: type aliases vs interface¶
Concept. Both name a shape. Rule of thumb used in this repo: interface for object shapes you might extend; type for unions, primitives-with-meaning, and function types. type can express things interface cannot (e.g. a union).
Your exercise. Convert this interface to a type, then add, only possible with type, a Grade alias that is a union of four string literals 'Good' | 'Careful' | 'Avoid' | 'Unknown' (this is VerdictGrade in types.ts).
interface Verdict { grade: string; reasons: string[]; }
Solution
type Grade = 'Good' | 'Careful' | 'Avoid' | 'Unknown';
type Verdict = {
grade: Grade; // tightened from string to the literal union
reasons: string[];
};
A union like *Grade* can't be written as an *interface*: that's the case where
*type* is the only option.