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 '' is a placeholder filled in at the call site, so one definition works for many element types while staying fully type-checked.

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(xs: T[]): T | undefined' that returns the first element or undefined for an empty array. Call it on a number[] and a string[] and confirm the inferred return types differ (number | undefined vs string | undefined).

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.