How vetd works
vetd checks whether a patch breaks a project's types before the patch is written to disk. It keeps a TypeScript 7 compiler (tsgo) running, fingerprints each file's exported types, and sends the compiler only the files a patch can affect.
Every underlined file or function opens its source. Numbered references open the paper or post behind a claim.
1. Overview
Most edits don't change what other files can see. A fix inside a function with a declared return type is invisible to its importers. vetd re-checks a file only if something it imports changed its exported types. Build systems call this early cutoff 1; vetd applies it per exported name.
A check runs these steps and stops at the first one that has the answer.
- 1Applies the patch in memoryThe patch changes nothing
- 2Re-reads the changed files
- 3Compares tokens, ignoring comments, whitespace and JSX textOnly those changed, in a file with no errors
- 4Compares export fingerprintsNone changed
- 5Finds importers of the changed namesThere are none
- 6Compares printed types, when 12+ files would be recheckedThe types are equal
- 7Type-checks the remaining files with tsgo
- 8Compares with the errors on diskA new error is found
- 9Runs the Next.js rules on the changed files
- endAnswer returned to the agent: ok, or the list of new errors
The patch lives in an in-memory overlay that the compiler reads first. Real writes go through writeAtomic and removeInside in src/core/vfs.ts. When an edit can't be proven isolated, vetd checks more files. A check that can't run returns NOT_CHECKED and ok: false.
2. Tokenizer
vetd doesn't build a syntax tree. src/core/lexer.ts makes one pass over the source and stores tokens in typed arrays (kind, start, end, punctuation code), about 11 bytes per token.
The hard part is context. A / divides after an expression and starts a regex elsewhere; < can be a comparison, a generic or JSX 4. The lexer keeps a mode stack (code, template, JSX tag, JSX children, JSX expression) and looks at the previous token to decide. A file whose brackets don't balance is marked broken and its exports count as unknown.
3. Export surfaces
A file's surface is every token that can affect its exported types. src/core/scan-surface.ts replaces the rest with markers before hashing:
- Bodies of functions with a declared return type:
function f(): number { ... }becomes{T}. - Initializers of annotated top-level variables:
const x: Foo = ...becomes=T. - Bodies of un-annotated functions that only return JSX or
null, checked byreturnShape. This covers React components, whose inferred type doesn't depend on the JSX.
Each top-level statement is hashed. An export's hash covers the statements its name reaches, every non-declaration statement (such as Button.displayName = "Button", which changes Button's type), and the imports they use.
const closure = (startNames, selfStmt) => {
const visit = (idx) => {
if (seenS.has(idx)) return;
seenS.add(idx);
for (const ref of stmts[idx].refs) queue.push(ref);
};
stmts.forEach((st, idx) => { if (!st.isDecl) visit(idx); });
while (queue.length) {
const nm = queue.pop();
// import binding: record a dependency. local declaration: visit its statement.
}
return { stmts: [...seenS].sort((a, b) => a - b), deps: [...deps].sort() };
};4. Hashing
src/core/hash.ts uses cyrb53 5, a 53-bit non-cryptographic string hash. The risk is an edit that changes a surface but keeps its hash, so an importer gets skipped. For an ideal hash that's about 1 in 10^16 per changed statement. A bug in the lexer or surface rules is far more likely than a collision.
5. Propagation
computeImpact in src/engine/impact.ts turns changed files into files to re-check:
- Every changed file is re-checked.
- If its surface hash is unchanged, stop.
- Otherwise the exports whose hash moved, or that were added or removed, are the changed names.
- An importer is re-checked only if it uses a changed name. Namespace imports,
export *, dynamic imports and deleted modules count as using every name. - The importer's own exports that reference a changed name become its changed names. Repeat from step 4.
Each re-check records why:
edited: body only (no export surface changed → early cutoff)
edited: surface of formatPrice, Price changed
uses formatPrice of lib/money.ts, whose types changed
edited: surface tokens of formatPrice changed but tsgo proves the exported types identical → semantic cutoffImport cycles terminate because a file is queued again only when its set of changed names grows, and that set can't exceed its export count:
const merged = new Set([...(prev ?? []), ...next]);
if (!prev || merged.size > prev.size) { changed.set(imp, merged); queue.push(imp); }6. Semantic cutoff
Renaming a parameter or writing Array<T> instead of T[] changes tokens but not types. When a change would re-check 12 or more files, src/engine/semantic.ts prints each changed export's type in the old and new program, strips parameter names, and compares. Equal types count as unchanged. It probes up to 32 exports, most-imported first.
if (!fallback && !diskEqual && impact.recheck.size >= semanticMinFanout()) {The threshold is set by VETD_SEMANTIC_MIN. The case that motivated it is excalidraw, where one export reaches App.tsx and from there about 370 files.
7. Compiler integration
vetd never loads the full tsconfig. It serves tsgo an in-memory tsconfig that extends the real one and lists only:
- the working set (files a check needed, up to 4,000)
- global-scope roots (scripts,
declare global,declare module) - files the patch creates
- a stub that re-imports packages which add globals, so
processand similar names still resolve
A smaller program means faster snapshot updates and less memory.
tsc --incremental tracks a signature per file 67. vetd tracks one per export and adds the semantic probe. It needs @typescript/native-preview 7.0.0-dev.20260707 or newer 23.
8. The Go patch
go/build.sh fetches the exact typescript-go commit the project has installed, adds one function to the checker, and builds it. go/vetd_hooks.go:
// VetdCheckNodes type-checks only the given top-level statements of sf, plus
// the deferred work they register (function bodies, …), and returns the
// diagnostics this checker holds for sf. Per-declaration re-checking: after a
// body-only edit, only the edited declaration needs the checker.
func (c *Checker) VetdCheckNodes(ctx context.Context, sf *ast.SourceFile, nodes []*ast.Node) []*ast.Diagnostic {
c.ctx = ctx
c.checkSourceElements(nodes)
c.checkDeferredNodes(sf)
c.ctx = nil
return c.diagnostics.GetDiagnosticsForFile(sf.FileName())
}vetd rejects a binary built from different sources or a different tsgo commit. src/engine/declcheck.ts uses it only when re-checking single statements is exact: the statement skeletons, imports and statement count are unchanged, both versions parse, and there are no @ts- directives or noUnused* options. Diagnostics outside the edited ranges are reused.
The Go backend is optional and ships prebuilt for macOS and Linux. Without a matching binary, vetd uses stock tsgo --api.
9. Forks and worktrees
A fork is a copy of the overlay, graph and cached results (src/engine/fork.ts). Forks share the same tsgo processes; switching costs one snapshot update.
There's one daemon per repo, app folder and dependency set. Another worktree with the same dependencies attaches as a fork. Idle tsgo stops after 5 minutes, the daemon after 10, and forks after 2 hours. A crashed child is respawned; if it dies twice in one check, the result is ok: false.
10. Self-verification
| Check | What it does | What it misses |
|---|---|---|
verify() | Rechecks the files vetd picked in a second tsgo | Files vetd should have picked |
| Oracle | Diffs a full tsgo --noEmit of base and fork 8 | Nothing; costs two full checks |
| Replay | Runs both on real commits, one at a time | Whatever those two miss |
engine/oracle.ts materializes the fork in a temp directory with hardlinks and symlinked node_modules.
11. Benchmarks
From site/benchmarks.json, 2026-11-11, on an M-series Mac (10 CPUs, 24 GB), Node 24.20, tsgo 7.0.0-dev.20260707.2. Each repo replays its last 30 commits, with an oracle run every 5th.
| Repo | Full check | vetd median | Result |
|---|---|---|---|
| taxonomy | 246 ms | 65 ms | 3.8x faster |
| commerce | 1265 ms | 432 ms | 2.9x faster |
| saas-starter | 511 ms | 275 ms | 1.9x faster |
| excalidraw | 302 ms | 1395 ms | 4.6x slower |
| hono | 65 ms | did not run | |
| zod | did not run |
Hono failed during replay and zod during install.
| Repo | Files | Incremental commits | Oracle mismatches |
|---|---|---|---|
| commerce | 65 | 13 of 30 | 112 |
| saas-starter | 41 | 11 of 30 | 39 |
| excalidraw | 695 | 19 of 30 | 1 |
| taxonomy | 125 | 23 of 30 | 37 |
Every repo has TypeScript 7 config errors in its baseline (removed options like baseUrl and moduleResolution=node10), so the full-check times are probably too low. Excalidraw's 302 ms for 695 files is the least believable. The other commits fell back to a full check because vetd couldn't prove the edit isolated, for example after a package.json change.
12. Limits
vetd can pass a patch the compiler would reject.
- Oracle, 189 mismatches. All in the safe direction: vetd reported errors the full compiler didn't, mostly TS2307 (cannot find module). Cause not yet known; it may be the config errors above.
verify(), 22 mismatches. On excalidraw and taxonomy, vetd missed errors, mostly TS2305 and TS2459. Becauseverify()shares vetd's graph, the true miss count is unknown.
Treat ok: true as an early answer for agents, not a replacement for a full check and build in CI (docs/compatibility.md).
- macOS and Linux only, Node 22.18+.
- Full rules for the Next.js App Router. Pages Router gets routes and links only.
- The diagnostics cache is off by default after an earlier bug gave a false pass.
- Six oracle runs per repo is a small sample.
13. References
- A. Mokhov, N. Mitchell, S. Peyton Jones. Build Systems à la Carte. ICFP 2018. https://www.microsoft.com/en-us/research/publication/build-systems-la-carte/
- A. Hejlsberg. A 10x Faster TypeScript. TypeScript blog, March 2025. https://devblogs.microsoft.com/typescript/typescript-native-port/
- Microsoft. microsoft/typescript-go. https://github.com/microsoft/typescript-go
- Ecma International. ECMAScript Language Specification, Lexical Grammar. https://tc39.es/ecma262/multipage/ecmascript-language-lexical-grammar.html
- bryc. cyrb53, 2018. https://github.com/bryc/code/blob/master/jshash/experimental/cyrb53.js
- S. Nandi. microsoft/TypeScript PR #48616, 2022. https://github.com/microsoft/TypeScript/pull/48616
- S. Nandi. microsoft/TypeScript PR #35711, 2020. https://github.com/microsoft/TypeScript/pull/35711
- W. M. McKeeman. Differential Testing for Software. Digital Technical Journal 10(1), 1998. https://www.hpl.hp.com/hpjournal/dtj/vol10num1/vol10num1art9.pdf