AI-Native Updated 2026-09 9 min read View as Markdown

The type system is the best prompt you will ever write

A type is a specification the compiler enforces on every edit, for free, forever. That makes TypeScript unusually well suited to a workflow where a machine writes most of the code.

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.

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.

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."

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#

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 works — see 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.

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.

Get the TypeScript agent pack

A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for TypeScript. One email, then occasional updates when the tooling shifts. No course pitch.

Unsubscribe in one click. We never sell the list. Or just take the AGENTS.md now — no email needed.

Disclosure: some links on this page are affiliate links. If you buy something through one, we earn a commission at no extra cost to you. We only list tools we would tell a friend to use, and we say so when we have not used something ourselves. This is how the site stays free and ad-light.