# The type system is the best prompt you will ever write

> Source: https://learn-typescript.org/ai/types-as-harness/
> Part of Learn TypeScript, free to read.

There is a way of thinking about types that becomes much more compelling once an agent is writing your code: **a type is a specification that gets checked on every single edit, at zero marginal cost, that the model cannot talk its way around.**

You can write the same constraint as a paragraph in `AGENTS.md` and hope it is weighted highly. Or you can write it as a type and have it be true.

## Start with a config that actually constrains

`strict: true` is the floor, not the ceiling. The four flags below are the ones that matter most for generated code, and all four are off by default even in strict mode.

```json tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,      // arr[0] is T | undefined. it is.
    "exactOptionalPropertyTypes": true,    // {a?: string} is not {a: undefined}
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "verbatimModuleSyntax": true,
    "erasableSyntaxOnly": true,
    "target": "ES2023",
    "module": "nodenext"
  }
}
```

`noUncheckedIndexedAccess` is the highest-value one by a distance. Array and record access is where generated code assumes presence most often, and this flag turns every such assumption into a compile error you must answer.

:::warn It will produce errors on your existing code
That is the flag doing its job — each error is a place where you were already assuming something you had not checked. Turn it on for new code first if the volume is large, and fix inwards.
:::

## Make illegal states unrepresentable

The single highest-leverage habit. Generated code fills in whatever the type permits, so a permissive type is an invitation.

```ts
// Permissive: 16 possible states, 12 of them nonsense
type Request = {
  status: "idle" | "loading" | "success" | "error";
  data?: User;
  error?: Error;
};

// Constrained: 4 states, all valid
type Request =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: User }
  | { status: "error"; error: Error };
```

With the second version, generated code that tries to read `data` in the error branch does not compile. You did not have to notice; the compiler did.

Add an exhaustiveness check and adding a new variant becomes a compile error at every site that needs updating:

```ts
function assertNever(x: never): never {
  throw new Error(`Unhandled: ${JSON.stringify(x)}`);
}

switch (req.status) {
  case "idle":    return null;
  case "loading": return <Spinner />;
  case "success": return <Profile user={req.data} />;
  case "error":   return <Error e={req.error} />;
  default:        return assertNever(req);
}
```

## Branded types for the things that get swapped

Every codebase has three string ids that must never be interchanged, and generated code will eventually interchange them because they are all `string`.

```ts
declare const brand: unique symbol;
type Brand<T, B> = T & { readonly [brand]: B };

type UserId = Brand<string, "UserId">;
type OrgId  = Brand<string, "OrgId">;
type Cents  = Brand<number, "Cents">;

export const userId = (s: string): UserId => s as UserId;

function transfer(from: UserId, to: UserId, amount: Cents): void {}

transfer(orgId, userId, 500);   // compile error. good.
```

Cheap to add, and it converts an entire category of "passed the wrong id" bug into a compile failure. Do it for money especially — `Cents` as a branded integer prevents both the float bug and the "was that dollars?" bug at once.

## Validate at the boundary, infer inside it

The most common structural failure in generated TypeScript: casting external data to a type and trusting it.

```ts
const user = await res.json() as User;   // a lie the compiler cannot check
```

`as` is an assertion, not a check. Parse instead, and let the type flow out of the parser:

```ts
import { z } from "zod";

const User = z.object({
  id: z.string().brand<"UserId">(),
  email: z.email(),
  createdAt: z.iso.datetime(),
});
type User = z.infer<typeof User>;

const user = User.parse(await res.json());  // now the type is earned
```

One schema, one source of truth, runtime validation and a static type. Put a line in `AGENTS.md`: *"External data is parsed with a schema. `as` on a network or database response is a bug."*

:::tip The grep that finds most of it
```bash
git diff | grep -nE ' as [A-Z]| as any|: any|@ts-ignore|@ts-expect-error|!\.'
```
Non-null assertions (`!`) and `as` are how generated TypeScript escapes the type system when it is stuck. Every occurrence in a diff deserves a look; most should be a parse or a narrowing check instead.
:::

## What the compiler still cannot see

Honesty about the limits, because "we have types" makes people complacent:

- **Types are erased.** They constrain the code, not the data. Anything crossing a process boundary needs runtime validation.
- **`any` is contagious** and generated code reaches for it under pressure. Turn on `noImplicitAny` (strict does) and lint against explicit `any`.
- **Structural typing means shape, not meaning.** Two types with the same fields are interchangeable. That is what branding is for.
- **A type says nothing about correctness.** `add(a: number, b: number): number` is satisfied by subtraction. Types constrain the space; tests pick the point.

## The setup

```json package.json
{
  "scripts": {
    "check": "tsc --noEmit && eslint . && vitest run",
    "typecheck": "tsc --noEmit"
  }
}
```

Wire `tsc --noEmit` into an edit hook so the agent gets type errors within a second of writing them, the same way [the Python loop](https://learn-python.com/ai/feedback-loops/) works — see [harness hooks](https://codelearningdojo.com/harness-hooks/) for how to set that up. On a large codebase use `tsc --noEmit --incremental` or project references so it stays fast enough to run every time.

:::promo frontendmasters
:::

## Common questions

### Do stronger types actually improve agent output?

Yes, and the mechanism is not subtle: the model gets a specific error naming the exact problem within a second, and iterates against it. A loose type produces code that compiles and is wrong, which produces no signal at all. This is the same reason a fast test suite beats a slow one.

### Is `noUncheckedIndexedAccess` worth the noise?

On new code, unquestionably — array access without a presence check is one of the most common generated bugs, and the flag makes it impossible. On a large legacy codebase, enable it per-directory and expand, rather than fixing several hundred errors at once.

### Zod, Valibot, ArkType, or something else?

Any of them. The decision that matters is *parse at the boundary rather than cast*, and all of these do it. Zod has the largest ecosystem, which also means models write it most reliably.

### Does this replace tests?

No — types constrain the space of possible programs, tests pin down which point in that space you wanted. `subtract` satisfies a signature that says `add`. Use types to make whole categories of error impossible, then test the behaviour.
