Back to blog
TypeScript
10 min read

I built a type checker that made AI stop lying to me

AI will use `any` to escape a type error. It will add eslint-disable. It will lie to you. Here's how we fixed the feedback loop.

AI doesn't lie to you because it wants to. It lies to you because you let it.

The broken feedback loop

Ask Claude Code to add a feature. It does. Run ESLint - it passes. Run TypeScript - errors. Ask Claude Code to fix the TypeScript. It does. Run ESLint - now that fails. Back and forth. Three iterations. The AI is confident at every step. Each time: "That's fixed."

It was never fixed.

It was whack-a-mole with a tool that runs one linter at a time and never sees the full picture.

The AI fixes the TypeScript error by writing one line and tells you: "The type error is resolved."

typescript
1const result = data as unknown as MyExpectedType;

Yes. Technically. The way covering your smoke detector with tape resolves a fire alarm.

That is what I built @next-vibe/checker to stop.

The `any` problem

Why 98% type safety is the same as 0%

TypeScript's type system is a graph. Every type flows from definition to usage. If you have a function that returns `string`, the caller knows it's a string. The whole chain is checked.

any
result
transformed
output
Component
hook
schema

One `any` type spreads through the graph, corrupting downstream type inference

`any` is a hole in the graph.

A variable typed as `any` tells the compiler: stop checking here. Not just for this variable - for everything that touches this variable. The error doesn't show up at the `any`. It shows up three files away when some unrelated refactor breaks an assumption that was never enforced.

0

TypeScript errors

47

`any` usages

Zero TypeScript errors means nothing if you have 47 unchecked `any` usages.

`as unknown as Whatever` is worse. It's a double type assertion. You're telling the compiler: I know this is wrong, and I'm asserting my way through it anyway. This is AI's favorite escape hatch.

The banned patterns in this codebase:

anyno-explicit-any
as unknown asno-unsafe-assignment
@ts-expect-errorban-ts-comment
throwrestricted-syntax
unknownrestricted-syntax
objectrestricted-syntax

Not warnings. Errors. The check fails. Claude Code has to fix the root cause or it can't ship.

The reason these are errors and not warnings is psychological as much as technical. AI models treat warnings as optional. Errors close the loop.

Introducing @next-vibe/checker

One command. Three tools. No escape hatches.

bash
1$ vibe check

That's the command. One command. It runs three tools in parallel and gives you one unified error list.

Oxlint

Rust-based linter. Hundreds of rules. Runs in milliseconds even on large codebases.

ESLint

The things Oxlint doesn't do yet: React hooks lint, React compiler rules, import sorting.

TypeScript

Full type checking. Not just the file you're editing - the whole graph.

in parallel

Unified error list

One output. Fix until clean.

On this codebase - 4,400 files - full TypeScript takes about 12 seconds. Oxlint is under a second. ESLint is a few seconds. Parallel brings it down to 12.

It also exposes a `vibe-check mcp` command that starts an MCP server with a `check` tool. The AI doesn't run a shell command - it calls a tool that returns structured error data. Pagination built in. Filtering by path.

Custom plugins

The linter is the documentation. And it's enforced.

jsx-capitalization plugin

It flags lowercase JSX elements and the error message tells you exactly what to import instead:

<button>β†’<Button> from "next-vibe-ui/ui/button"
<a>β†’<Link> from "next-vibe-ui/ui/link"
<p>β†’<P> from "next-vibe-ui/ui/typography"

I didn't write documentation telling Claude Code to use `next-vibe-ui`. I didn't add it to the system prompt. The first time Claude Code writes `<div>` in a component, the checker errors. The error message contains the exact import path. Claude Code reads the error, applies the fix, and remembers the convention.

restricted-syntax plugin

It bans three things:

`throw` statements

The error message says: "Use proper `ResponseType<T>` patterns instead." Claude Code hits this, reads it, looks up `ResponseType`, and adopts the correct error-handling pattern for the entire rest of the task.

bare `unknown` type

"Replace 'unknown' with existing typed interface. Align with codebase types rather than converting or recreating." This stops Claude Code from writing generic type escape hatches.

bare `object` type

`object` is almost always wrong. Either you know the shape - write the interface - or you have `Record<string, SomeType>`. Raw `object` is a signal that the AI gave up.

Banned patterns

BANNEDrestricted-syntax
typescript
1const result = response as unknown as MyType;
CORRECT
typescript
1const result: MyType = parseResponse(response);
BANNEDno-explicit-any
typescript
1function foo(x: any): any { return x.data; }
CORRECT
typescript
1function foo<T>(response: ResponseType<T>): T | null {
2  if (!response.success) return null;
3  return response.data;
4}
BANNEDrestricted-syntax
typescript
1throw new Error("Something failed");
CORRECT
typescript
1return fail({
2  message: t("errors.server.title"),
3  errorType: ErrorResponseTypes.INTERNAL_ERROR,
4});

Live demo: the 3-round pattern

Watch Claude Code hit the checker three times before finding the correct type

1

Round 1 - AI writes `any`

Ask Claude Code: "Write a helper function that takes a raw API response object and extracts the data field. The response can have different shapes - use whatever type makes this work."

Claude Code writes the easy solution:

typescript
1export function parseApiResponse(response: any): any {
2  return response.data;
3}
bash
1  1:37  error  Unexpected any.  typescript/no-explicit-any
2  1:44  error  Unexpected any.  typescript/no-explicit-any
3
42 errors found.
2

Round 2 - AI tries `unknown`

Watch what it does next. This is the important part. It tries the next escape route:

The checker knows that trick.

typescript
1export function parseApiResponse(response: unknown): unknown {
2  return (response as Record<string, unknown>).data;
3}
bash
1  1:37  error  Replace 'unknown' with typed interface.  restricted-syntax
2  1:44  error  Replace 'unknown' with typed interface.  restricted-syntax
3  1:56  error  Replace 'unknown' with typed interface.  restricted-syntax
4
53 errors found.
3

Round 3 - AI finds the real type

Now Claude Code does what it should have done first. It looks at how existing API responses are typed in this codebase. It finds `ResponseType<T>`.

Zero errors. And the function is now actually correct.

typescript
1import type { ResponseType } from "@/response-type";
2
3export function parseApiResponse<T>(
4  response: ResponseType<T>
5): T | null {
6  if (!response.success) return null;
7  return response.data;
8}
bash
1  Oxlint: 0 errors
2  ESLint: 0 errors
3  TypeScript: 0 errors
4
50 errors found.

The checker didn't write it. But the checker prevented the shortcut, twice, until Claude Code had to engage with the actual problem.

The endpoint connection

One Zod schema - four downstream consumers

Every endpoint has a definition file. That file contains one Zod schema for the request and one for the response.

The `schema` key is a Zod validator. That same Zod schema becomes:

typescript
1name: requestField(st, {
2  schema: z.string().min(1).max(255),
3  label: "name",
4  description: "description",
5  placeholder: "placeholder",
6}),

The validation rule on the web API endpoint

The TypeScript type for the React hook's input parameter

The `--name` flag in the CLI with min/max constraints applied

The parameter description in the AI tool schema

This is where drift usually kills you. You update the API. You forget to update the AI tool schema. The AI is calling the endpoint with the old parameter names. It fails silently.

When there's one schema, there's nothing to sync.

And because the TypeScript checker runs on this too - if you change the schema in a way that breaks the inferred type downstream, you get a compiler error. The AI tool schema is type-checked. The CLI flags are type-checked. The React hook is type-checked.

245

245 endpoints

0

Zero `any`

0

Zero `unknown` casts

0

Zero `@ts-expect-error`

Not by convention. By the checker.

Get @next-vibe/checker

Works on any TypeScript project. Not just next-vibe.

The checker is available as a standalone npm package. It works on any TypeScript project - not just NextVibe. You don't need any other part of the framework.

bash
1bun add -D @next-vibe/checker\n# or\nnpm install -D @next-vibe/checker

Then run:

bash
1vibe-check config    # scaffold check.config.ts\nvibe-check           # run all checks\nvibe-check --fix     # auto-fix linting issues

MCP integration

Add it to your Claude Code or Cursor MCP config. Now Claude Code calls `check` as a tool, not a shell command. Structured errors. Paginated. Filterable by path.

json
1{
2  "mcpServers": {
3    "vibe-check": {
4      "command": "vibe-check",
5      "args": ["mcp"],
6      "env": { "PROJECT_ROOT": "/path/to/project" }
7    }
8  }
9}

On the npm page there's also a migration prompt. Copy it into Claude Code or Cursor and it will audit your codebase, configure the checker, and migrate you to the banned patterns.

It's open source. GPL-3.0 for the framework, MIT for the checker package.

Build the system so lying is impossible

Before:

AI-assisted development was a negotiation. Fix the lint. Oh, now the types broke. Fix the types. Now there's an `any` you didn't notice. Fix that. Run three separate tools. Get three separate opinions. Never sure if it's actually clean.

After:

One command. One failure mode. Either it passes or it doesn't. The AI knows exactly what it has to fix because the errors tell it exactly what's wrong and what to do instead. No negotiation.

Build the system so lying is impossible. That's what a type checker is for.