Programming with AI
ENRU
9.5

Types as a check on the AI's work

five errors caught before the run, and one that gets through

What it is

The chapter's central article. Go back to chapter 4: ten articles there on catching by hand the errors you can't see when the code runs. Types do part of that work mechanically, at every check, for free.

Below are five real AI mistakes and the compiler's real messages for them. And then an honest limitation: one error of the same kind slips past, and knowing about it matters more than the other five.

What it looks like in code

1. Forgot the emptiness check

The most frequent AI mistake in the whole book:

function report(c: City): string {
  return `${c.name}: ${c.temp.toFixed(1)}°`;
}
measured
error TS18047: 'c.temp' is possibly 'null'.

2. Got a field name wrong

const c: City = { name: "Oslo", temperature: 4 };
measured
error TS2353: Object literal may only specify known properties,
and 'temperature' does not exist in type 'City'.

In JavaScript without types this would quietly have created an object with one extra field and without the one needed. The error would have surfaced somewhere else and looked like undefined.

3. Got the argument order wrong

function subscribe(userId: number, city: string): void {}
subscribe("Oslo", 7);
measured
error TS2345: Argument of type 'string' is not assignable
to parameter of type 'number'.

A classic when working with an AI: it remembers a function with a similar name and a different argument order.

4. Forgot a variant of a union

type Status = "paid" | "pending" | "refunded";

function label(s: Status): string {
  switch (s) {
    case "paid": return "paid";
    case "pending": return "awaiting";
  }
}
measured
error TS2366: Function lacks ending return statement
and return type does not include 'undefined'.

refunded is missing. Without types the function would silently have returned undefined, and an empty space would have appeared in the interface instead of a label.

This is the important case: add a fourth status tomorrow and the compiler will find every place that doesn't handle it. You don't find that by hand.

5. Indexing out of bounds

const cities = ["Oslo", "Bergen"];
const first: string = cities[5];
measured
error TS2322: Type 'string | undefined' is not assignable to type 'string'.

Only works with noUncheckedIndexedAccess (article 9.3).

Where types don't help

Now, honestly. Here is code the compiler lets through without a single error:

interface WeatherResponse { main: { temp: number }; name: string }

async function fetchWeather(city: string): Promise<WeatherResponse> {
  const raw = '{"cod":"404","message":"city not found"}';
  return JSON.parse(raw) as WeatherResponse;
}

const data = await fetchWeather("Tonsberg");
console.log(data.main.temp);
measured
tsc: no errors
measured
failed: Cannot read properties of undefined (reading 'temp')

as WeatherResponse is a promise to the compiler, not a check. You said "trust me, this has that shape", and it did. Something else arrived — and there is the very same Cannot read properties of undefined that chapter 8 opened with.

Diagram from the book — to be redrawn for the web
Types check your code against itself. Data that arrived from outside they only describe — that has to be checked separately.

The boundary is everything that arrived from outside the program:

  • an API response (fetch, JSON.parse);
  • form data;
  • localStorage;
  • parameters from the page's address.

What closes the boundary

Run-time validation libraries — zod, valibot and the like. They do for the frontend what pydantic does for the backend (article 7.3): you describe the shape once, and it both checks the data and gives you a type.

Prompt
The API response is currently cast with `as`. Replace it with
a run-time check using zod: schema, parse, a clear error if
the shape doesn't match. Infer the type from the schema, don't
duplicate it by hand.

That last sentence matters: the point is for the shape to be described once and serve as both the check and the type. Two descriptions side by side will drift apart.

What to tell the AI

Types are also a way of stating the task. A signature is more precise than words (articles 3.4, 6.5):

Prompt
Write a function to this signature:

async function loadSubs(userId: number): Promise<Result<City[]>>

where Result<T> = { ok: true; data: T } | { ok: false; error: string }.
Don't throw exceptions outward, return errors as the second
variant. tsc --noEmit with strict must pass.

Checking finished code:

Prompt
Run tsc --strict over your own code in your head: what errors
would it find? Fix them without using any, as or ts-ignore.

The prohibition in the last line is mandatory — otherwise the "fix" will be a gag (article 9.6).

Cheat sheet

  • Types catch before the run: a forgotten emptiness, a wrong field, argument order, a missed union variant, going past the end of an array.

  • Add a variant to a union and the compiler finds every unhandled place.

  • as is a promise to the compiler, not a check. It doesn't look at outside data.

  • The boundary: API responses, forms, localStorage, the page's address.

  • The boundary is closed by a run-time check, zod for example.

  • Infer the type from the schema instead of duplicating it alongside.