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."
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.
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:
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.
1$ vibe checkThat'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.
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:
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
1const result = response as unknown as MyType;1const result: MyType = parseResponse(response);1function foo(x: any): any { return x.data; }1function foo<T>(response: ResponseType<T>): T | null {
2 if (!response.success) return null;
3 return response.data;
4}1throw new Error("Something failed");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
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:
1export function parseApiResponse(response: any): any {
2 return response.data;
3}1 1:37 error Unexpected any. typescript/no-explicit-any
2 1:44 error Unexpected any. typescript/no-explicit-any
3
42 errors found.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.
1export function parseApiResponse(response: unknown): unknown {
2 return (response as Record<string, unknown>).data;
3}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.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.
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}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:
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.
1bun add -D @next-vibe/checker\n# or\nnpm install -D @next-vibe/checkerThen run:
1vibe-check config # scaffold check.config.ts\nvibe-check # run all checks\nvibe-check --fix # auto-fix linting issuesMCP 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.
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.
Chat, create, and connect - text, images, video, and music
Privacy-first AI with 119 models - chat, images, video & music
Β© 2026 unbottled.ai. All rights reserved.