Development Playbook

Make the compiler refuse your agent's type casts: a lint gate and a Zod boundary, built in 15 minutes

TypeScript accepts `body as Order` without a murmur, and so does every agent that wants green. A typed-lint gate and a schema at the edge of the system make the lie impossible to commit. Fifteen minutes to set up.

A billowing gold ink plume stopped by a vertical glass pane, with a single thin clean sheet of ink passing through to the other side

An agent is asked for a function that fetches an order from an internal API. It writes six tidy lines, the compiler is happy, the tests it wrote pass, and the diff looks like every other diff. The sixth line is return body as Order;, and that line is a promise the code can’t keep. The first time the API returns a quantity as a string, unitCount quietly returns "023" instead of 5 and the bug surfaces three services later.

TypeScript doesn’t catch this because it was told not to. res.json() returns any, and a type assertion tells the compiler to stop asking. Agents reach for it constantly, because it’s the shortest path from red to green and nothing in the usual tsc plus tests loop pushes back.

This Playbook builds the push-back: a typed-lint configuration that fails on casts and on unchecked any, a schema at the boundary that turns the cast into real validation, and a test that feeds the function bad payloads. Everything below was run with TypeScript 6.0.3, ESLint 10.14.0, typescript-eslint 8.72.1, Vitest 5.1.0 and Zod 4.6.7. The setup is plain files and npm scripts, so it works with any coding agent that can read files and run a shell.

The code that passes everything it’s asked to pass

Start with the agent-style version. Put this in src/orders.ts:

export interface Order {
  id: string;
  status: "open" | "paid" | "refunded";
  lines: { sku: string; qty: number }[];
}

export async function fetchOrder(id: string, http: typeof fetch = fetch): Promise<Order> {
  const res = await http(`https://api.example.test/orders/${id}`);
  const body = await res.json();
  return body as Order;
}

export function unitCount(order: Order): number {
  return order.lines.reduce((sum, line) => sum + line.qty, 0);
}

Run npx tsc -p . with strict: true and it exits 0. Nothing is wrong as far as the compiler can tell, and that’s the problem. The Order interface describes what the author hopes the API sends, and the runtime never checks it.

To see what that costs, feed the reducer two lines whose quantities arrived as strings:

node -e 'console.log([{qty:"2"},{qty:"3"}].reduce((s,l)=>s+l.qty,0))'
023

That’s string concatenation starting from the 0 accumulator. No exception, no log line, and a wrong number on an invoice.

Step 1: turn on typed linting that sees any

Plain ESLint can’t tell that body is any, because that needs the type checker. typescript-eslint’s type-checked presets can. Install the pieces:

npm i -D typescript@6.0 eslint typescript-eslint vitest zod @types/node

The typescript@6.0 pin is deliberate. At the time of writing npm resolves typescript@latest to 7.0.2, while typescript-eslint 8.72.1 declares a peer range of >=4.8.4 <6.1.0, and a plain install fails with an ERESOLVE error. If you’re moving to the new compiler, the two-compiler diff in this earlier post covers it. Keep linting on 6.0 until typescript-eslint widens its range.

Now eslint.config.mjs:

import tseslint from "typescript-eslint";

export default tseslint.config(
  ...tseslint.configs.recommendedTypeChecked,
  {
    languageOptions: { parserOptions: { projectService: true } },
    rules: {
      "@typescript-eslint/consistent-type-assertions": ["error", { assertionStyle: "never" }],
      "@typescript-eslint/no-non-null-assertion": "error",
      "no-restricted-syntax": [
        "error",
        {
          selector: "CallExpression[callee.object.name='z'][callee.property.name=/^(any|unknown)$/]",
          message: "z.any() and z.unknown() switch validation off. Describe the shape.",
        },
      ],
    },
  },
);

The first line spreads in the recommendedTypeChecked preset, which includes the no-unsafe-* family: assignment, argument, call, member access and return. projectService: true is how the typed-linting docs tell the parser to find your tsconfig. The three rules after it are the part that matters for agents:

  • consistent-type-assertions with assertionStyle: "never" bans as and angle-bracket assertions outright. Tests that need a deliberate cast can opt out with an inline disable and a reason, which then shows up in review.
  • no-non-null-assertion bans the ! postfix, the other way to tell the compiler to look away.
  • The no-restricted-syntax selector closes the loophole you’re about to create. Once validation is the rule, the cheapest cheat is a schema that accepts everything.

Add "check": "tsc -p . && eslint src test && vitest run" to package.json scripts, and run the linter on the agent’s version:

npx eslint src
src/orders.ts
   9:9   error  Unsafe assignment of an `any` value  @typescript-eslint/no-unsafe-assignment
  10:10  error  Do not use any type assertions       @typescript-eslint/consistent-type-assertions

✖ 2 problems (2 errors, 0 warnings)

Two errors on the two lines that matter. The compiler said nothing about either.

Step 2: write the test that the cast fails

A lint rule tells the agent what not to do. A test tells it what correct looks like. Put the bad payloads in a table so adding a new one is a single line. In test/orders.test.ts:

import { describe, expect, it } from "vitest";
import { fetchOrder, unitCount } from "../src/orders";

function stub(payload: unknown): typeof fetch {
  return () => Promise.resolve(new Response(JSON.stringify(payload), { status: 200 }));
}

describe("fetchOrder", () => {
  it("returns a well-formed order", async () => {
    const order = await fetchOrder("o-1", stub({ id: "o-1", status: "paid", lines: [{ sku: "A", qty: 2 }] }));
    expect(unitCount(order)).toBe(2);
  });

  it.each([
    ["lines missing", { id: "o-2", status: "open" }],
    ["qty is a string", { id: "o-3", status: "open", lines: [{ sku: "A", qty: "2" }] }],
    ["unknown status", { id: "o-4", status: "shipped", lines: [] }],
  ])("rejects a payload where %s", async (_name, payload) => {
    await expect(fetchOrder("x", stub(payload))).rejects.toThrow();
  });
});

The function takes http as a parameter defaulting to the global fetch, so the test injects a stub and no network is involved. That’s a design choice worth asking the agent for in the first place.

Run npx vitest run against the cast version and three of the four tests fail:

 ❯ test/orders.test.ts (4 tests | 3 failed)
   ❯ fetchOrder (4)
     × rejects a payload where lines missing
     × rejects a payload where qty is a string
     × rejects a payload where unknown status
 AssertionError: promise resolved "{ id: 'o-2', status: 'open' }" instead of rejecting

The cast hands back whatever arrived, so the function resolves with an object that violates its own return type. Now there’s a failing test and two lint errors, both pointing at the same defect. That’s what an agent needs: a red state it can’t talk its way around.

Step 3: let the agent fix it, with this prompt

Give the agent the failing state and the constraint, not a solution:

npm run check fails on src/orders.ts: two lint errors and three failing
tests in test/orders.test.ts. Make it pass by validating the response at
the boundary with a Zod schema and deriving the Order type from that
schema with z.infer. Do not use type assertions, non-null assertions,
z.any() or z.unknown(), and do not edit the tests or eslint.config.mjs.
Run npm run check and show me the output.

The last clause matters more than it looks. An agent under pressure to turn a check green will sometimes weaken the check. Say that the tests and the lint config are read-only for this task, and look at the diff to confirm they are.

The result should look like this, and it’s what the run produced:

import { z } from "zod";

const OrderSchema = z.object({
  id: z.string(),
  status: z.enum(["open", "paid", "refunded"]),
  lines: z.array(z.object({ sku: z.string(), qty: z.number().int().positive() })),
});

export type Order = z.infer<typeof OrderSchema>;

export async function fetchOrder(id: string, http: typeof fetch = fetch): Promise<Order> {
  const res = await http(`https://api.example.test/orders/${id}`);
  return OrderSchema.parse(await res.json());
}

export function unitCount(order: Order): number {
  return order.lines.reduce((sum, line) => sum + line.qty, 0);
}

Two things are worth noticing. The interface is gone: the schema is the single definition and the type is derived from it, so the two can’t drift. And OrderSchema.parse(await res.json()) passes an any into a parameter typed unknown, which no-unsafe-argument allows, because nothing is read from the value before it’s checked. Per the Zod docs, .parse returns a strongly typed deep clone on success and throws a ZodError on failure.

Run the gate:

npm run check
> check
> tsc -p . && eslint src test && vitest run
 Test Files  1 passed (1)
      Tests  4 passed (4)

What the error looks like when it fires

Failing loudly is only useful if the failure says what was wrong. With the status shipped and a string quantity in the same payload, the thrown message is a JSON array of every issue, not just the first:

[
  {
    "code": "invalid_value",
    "values": ["open", "paid", "refunded"],
    "path": ["status"],
    "message": "Invalid option: expected one of \"open\"|\"paid\"|\"refunded\""
  },
  {
    "expected": "number",
    "code": "invalid_type",
    "path": ["lines", 0, "qty"],
    "message": "Invalid input: expected number, received string"
  }
]

(Whitespace trimmed.) The path pins down which field, and both problems arrive together, which saves a round trip for whoever reads the log. At a service boundary you’ll usually want safeParse instead, which returns a result object so you can map failures to a 502 or a retry and not an unhandled exception. The Zod docs on formatting errors cover z.treeifyError and friends for turning issues into something readable.

Check that the gate can’t be gamed

A gate is only as good as the cheats it stops. Try the easiest one: replace the lines definition with z.any(), which makes the types and the happy-path test pass.

npx eslint src
src/orders.ts
   6:10  error  z.any() and z.unknown() switch validation off. Describe the shape  no-restricted-syntax
  17:3   error  Unsafe return of a value of type `any`                             @typescript-eslint/no-unsafe-return
  17:10  error  Unsafe call of an `any` typed value                                @typescript-eslint/no-unsafe-call
  17:22  error  Unsafe member access .reduce on an `any` value                     @typescript-eslint/no-unsafe-member-access
  17:44  error  Unsafe return of a value of type `any`                             @typescript-eslint/no-unsafe-return
  17:55  error  Unsafe member access .qty on an `any` value                        @typescript-eslint/no-unsafe-member-access

✖ 6 problems (6 errors, 0 warnings)

The custom rule fires on the schema, and the no-unsafe-* rules independently catch the any flowing into unitCount. Two separate defences agree, which is why the restricted-syntax rule is a message for the agent more than a net: the any would have been caught anyway, but the first message says what to do instead.

The gate is not airtight. An agent can add // eslint-disable-next-line, rewrite eslint.config.mjs, or write a schema that’s too loose (z.string() for a field that should be an enum). The first two show up in the diff as edits to files the task said were off limits. The third is a review question, and it’s why the test table has a row for each field that has a restricted set of values.

Make it yours

The pattern isn’t about orders. Anywhere untyped data enters the process, the same shape applies: an HTTP response, a request body in Express or Fastify, JSON.parse of a file, a message from a queue, process.env, anything read from localStorage. Each gets one schema, one parse call at the edge, and a type derived from the schema for everything inside.

For an existing codebase with hundreds of casts, don’t turn the rule on for the whole tree and drown. Run eslint --format json with the strict config, count errors per directory, and take the directories with the most boundary code first (API clients, route handlers). Everything else stays on the old config via an override until you reach it. The one-flag-at-a-time migration uses the same ratchet idea for compiler flags.

If your team prefers another schema library, the structure is identical: any library whose parse function accepts unknown and returns a typed value fits. Only the restricted-syntax selector changes, so it bans that library’s own escape hatch. Zod was used here because its API and error output were runnable and documented.

In CI, run the same script the agent runs. npm run check as a required status check means the gate applies to human changes and agent changes alike, and an agent that was told to run it locally can’t skip it by opening a PR.

What goes wrong

Validation costs CPU on every call. For a hot path handling large payloads, measure before deciding, and consider validating at the edge once and passing the typed value inward. Don’t parse the same object twice in the same request.

Strict schemas turn a tolerant API into a brittle one. If an upstream service adds a field, z.object ignores unknown keys by default (the Zod docs show z.strictObject for rejecting them). If it adds a new status value, your enum now rejects it. That’s usually what you want from an internal contract and not what you want from a third-party API you don’t control, where a looser schema plus alerting on parse failures is often the better trade.

Typed linting is slower than plain linting, since it runs the type checker over your project. On a large repo, keep it in the pre-push or CI path and leave the fast rules on save.

The assertionStyle: "never" setting is blunt. Test doubles, branded types and some library interop legitimately need an assertion. The answer is a targeted inline disable with a reason, which a reviewer sees, and not loosening the rule for everyone.

And a schema proves shape, not meaning. A well-formed order with a negative total in cents, or a refunded status on an order that was never paid, passes parse. Business rules still need their own tests.

The first 15 minutes

In a TypeScript project with Vitest, run npm i -D typescript@6.0 typescript-eslint eslint zod if you don’t have them, paste the config above, and run npx eslint src. Count the no-unsafe-assignment and consistent-type-assertions hits in your API client and route handler directories. Pick the one with the most traffic, write the bad-payload table for it, and hand the agent the prompt from Step 3.