Build a TypeScript Code Graph with the Compiler API
Turn a large TypeScript codebase into a queryable graph of symbols, calls and state access, so you can finally answer 'does this already exist?' in one command.

Have you ever written a helper function, opened a pull request, and had someone comment "we already have this"?
I have. More than once, in the same codebase. And once, on a helper I had written myself eight months earlier, under a name I no longer remembered choosing. That's the one that stayed with me.
There's a very particular flavour of embarrassment in that. Not "I didn't know the codebase," which is forgivable. More like finding out you've been introduced to yourself at a party.
And the genuinely infuriating part is that searching wouldn't have saved me. The function was there. It was called something I'd never have guessed in a hundred tries.
That's the problem this article is about. Not "how do I search my code" but something harder: how do I ask my codebase a question and get a real answer?
Three questions in particular:
Does this already exist?
Where does this code belong?
What will I break if I change it?
Grep can't answer any of them properly. But the TypeScript compiler can, and it's already sitting in your node_modules.
First, why grep runs out of road
Say you've got this import:
import { calculateOrderTotal as getCartValue } from "@/pricing";
const total = getCartValue(cart);
Now search for calculateOrderTotal. This file won't come up. The function is here, it's being called right now, but it's wearing a different name.
And even without the alias, @/pricing is almost certainly a barrel file re-exporting from somewhere else. Barrel files are wonderful for writing imports and actively hostile to everything that happens afterwards. The real declaration is two hops away from anything you typed.
Grep works on text. The TypeScript type checker works on meaning. Ask it what getCartValue refers to and it walks back through the alias, through the barrel, and hands you the actual declaration.
That difference is the whole article. Text search tells you a string appears in twelve files. The compiler tells you which symbols call which function. Only the second one is something you can safely refactor.
Why I actually built this
For a while I treated this as a personal failing. Learn the codebase better. Read more of it. Keep a list.
Then I watched a new engineer join and spend most of their first fortnight asking questions I couldn't answer cleanly either. Where does this belong, does something like this exist, what happens if I change this. I'd been there two years and I was guessing. Confidently, and often correctly, but guessing.
That's when it stopped feeling like a memory problem.
There's a threshold a codebase crosses where nobody holds it in their head anymore. Not the tech lead, not the person who wrote the first commit. Past that line you start making changes with a quiet undertone of I think this is fine, and you ship it, and mostly it is fine, and the times it isn't you find out from someone else's bug report.
I didn't want a better memory. I wanted to stop guessing.
What this looks like when it works
Before we build anything, here's the payoff:
$ node find-symbol.mjs "convert date to utc"
1. parseIsoTimestamp dates/parseIsoTimestamp.ts
2. normalizeServerDate dates/normalizeServerDate.ts
3. formatUtcDate display/formatUtcDate.ts
I typed what I wanted. It found things named nothing like it, in about a third of a second, and I have never once had to remember what any of them were called.
Some numbers from the codebase I built it on, so you know the scale we're talking about:
| Source files | 771 |
| Graph nodes | 2,795 |
| Graph edges | 17,985 |
| Searchable symbols | 873 |
| Rebuild time | 13.1 s |
| New dependencies | 0 |
The domain is anonymized and the examples below are generic, but every number is real.
Step 1: Ask TypeScript for a Program
A Program is TypeScript's view of your whole project: every file, resolved with the same config your app builds with.
import ts from "typescript";
const configPath = ts.findConfigFile("./", ts.sys.fileExists, "tsconfig.json");
if (!configPath) throw new Error("No tsconfig.json found");
const configFile = ts.readConfigFile(configPath, ts.sys.readFile);
const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, "./");
const program = ts.createProgram({
rootNames: parsed.fileNames,
options: parsed.options,
});
const checker = program.getTypeChecker();
Using the project's own tsconfig.json matters here. Your path aliases and allowJs settings need to match what actually compiles, or half your imports won't resolve.
Then walk the files:
for (const sourceFile of program.getSourceFiles()) {
if (sourceFile.isDeclarationFile) continue;
if (sourceFile.fileName.includes("node_modules")) continue;
visit(sourceFile);
}
function visit(node: ts.Node) {
if (ts.isFunctionDeclaration(node) && node.name) {
addNode({
id: `function:${node.name.text}`,
kind: "function",
name: node.name.text,
file: node.getSourceFile().fileName,
});
}
ts.forEachChild(node, visit);
}
You now have a list of every function in your project. Useful, but it's still just a list.
Tip: keep TypeScript AST Viewer open in a tab while you write this. Paste in a snippet, see exactly what node types come back. It'll save you an hour of guessing.
Step 2: Resolve symbols, not strings
Here's where it gets interesting. When you hit a call expression, ask the checker what's being called:
if (ts.isCallExpression(node)) {
const symbol = checker.getSymbolAtLocation(node.expression);
}
But if that call came through an import, you've got an alias. A pointer, not the thing itself. So unwrap it:
function resolveSymbol(symbol: ts.Symbol | undefined) {
if (!symbol) return undefined;
if (symbol.flags & ts.SymbolFlags.Alias) {
return checker.getAliasedSymbol(symbol);
}
return symbol;
}
That flag check isn't optional, and I found that out the way everyone does. getAliasedSymbol throws if you hand it something that isn't an alias. Not returns null, not returns the original. Throws. In a codebase with barrel files you'll hit both cases inside the same file, so guard it, or spend twenty minutes wondering why your build script dies on file three every single time.
Now record the relationship:
const resolved = resolveSymbol(symbol);
const declaration = resolved?.declarations?.[0];
if (declaration) {
addEdge({
from: currentSymbolId,
to: symbolId(resolved, declaration),
kind: "calls",
});
}
You're now storing a link between two real declarations instead of two strings that happen to match. Everything else builds on this.
Step 3: Keep the data model boring
type NodeKind = "file" | "function" | "component" | "hook" | "state" | "package";
type EdgeKind =
| "imports" | "calls" | "renders"
| "uses-hook" | "reads-state" | "writes-state";
interface GraphNode {
id: string;
kind: NodeKind;
name: string;
file?: string;
}
interface GraphEdge {
from: string;
to: string;
kind: EdgeKind;
}
Write it out as { "nodes": [], "edges": [] } and stop. I know "knowledge graph" sounds like it deserves something fancier, but plain JSON has one enormous advantage: it exists today, and you can query it with Array.prototype.filter.
Mine comes out at 5.5 MB. Gitignore it. We'll commit something much smaller in a bit.
Step 4: Teach it about your codebase
Everything so far is generic. If you stop here you've built a dependency graph, which dependency-cruiser and Madge already give you for free, and which you've just spent a weekend reinventing. Don't stop here.
The value comes from two functions you write yourself.
classifyFile(path) maps your folder conventions onto node kinds. src/hooks/ means hook, src/redux/slices/api/ means API client. Get this wrong and every query afterwards returns noise, because "show me the components that read this state" depends on the graph knowing what a component is.
detectFactoryKind(initializer) catches the things your framework creates through function calls. The AST just sees a const, so without help your graph is full of opaque constants where the interesting nodes should be. I detect createAsyncThunk, createSelector and createSlice. Yours might be defineStore, createMachine, or a DI registration.
Both are short, forty lines or so. They're also the difference between a generic import graph and a map of your system.
Now you can add edges nobody else could give you. When the walker sees this:
const cart = useAppSelector(state => state.cart);
it emits CheckoutPage → reads-state → cart. And when it sees a thunk:
export const refreshCart = createAsyncThunk("cart/refresh", async () => { ... });
it emits refreshCart → writes-state → cart.
Something I didn't expect. Those two state detectors produced 320 edges out of nearly 18,000, under 2% of the graph. They're also the ones people query. The compiler gives you thousands of edges for free; the handful you write yourself carry most of the weight.
The bug it found in its first week
I want to tell you about this one, because it's the moment the tool proved itself and it wasn't a feature.
My reads-state detector keys off the state shape, state.invoiceTemplates, plural. My writes-state detector keys off the name passed to createSlice({ name }), which turned out to be invoiceTemplate, singular.
Same slice. Two different names depending on which direction you asked.
My first reaction was that my detector was broken. My second, after ten minutes of checking, was the slightly cold realisation that it wasn't. The codebase had been carrying that inconsistency for years, and every one of us had walked past it.
Nobody had spotted it because no single file shows you both sides. You'd have to open the slice definition and a component that consumes it, at the same time, while specifically hunting for a mismatch that you have no reason to suspect exists. Nobody does that. Nobody has ever done that.
Expect a few of these. A graph that surfaces your naming inconsistencies is working correctly, even when the first thing it tells you is embarrassing.
Step 5: Make symbols searchable
A node gets far more useful once it carries more than a name:
{
"id": "function:parseIsoTimestamp",
"name": "parseIsoTimestamp",
"kind": "function",
"module": "dates",
"summary": "Convert an ISO timestamp into UTC epoch milliseconds",
"signature": "(value: string) => number"
}
The checker infers most of that for free, which is the nice part. You get a decent search index without asking anyone to maintain a catalog by hand.
One honest number before you get excited: in my index, 91% of symbols have a type signature and 24% have a written summary.
Signatures come from the compiler. Prose comes from humans. You can guess which one shows up reliably.
So if you want better search results cheaply, don't set out to document everything. Add doc comments to the shared helpers only, the forty things people keep re-implementing. That's where the return is.
Step 6: Search by intent
Now the search itself. The key idea is tokenization: toEpochMs and to_epoch_ms both break down into ["to", "epoch", "ms"]. That's what lets someone search for a thing they want and find a function named nothing like it.
This is the case grep structurally cannot handle. normalize_date and clean_timestamp share zero characters, so no regex will ever connect them, but both might be exactly what you meant.
The scorer is a weighted sum, and I'd encourage you to keep yours equally dull:
function scoreSymbol(symbol: SearchableSymbol, query: string) {
const q = normalize(query);
const name = normalize(symbol.name);
const summary = normalize(symbol.summary ?? "");
let score = 0;
if (name === q) score += 1000; // exact name
if (name.includes(q)) score += 60; // name substring
if (summary.includes(q)) score += 25; // summary substring
const queryTokens = tokenize(q);
const nameTokens = new Set(tokenize(name));
const summaryTokens = new Set(tokenize(summary));
for (const token of queryTokens) {
if (nameTokens.has(token)) score += 20;
if (summaryTokens.has(token)) score += 6;
}
return score;
}
Module match adds 15, path match 10. The exact numbers barely matter. You'll tune them against your own repo within a day of using it.
Wrap it in a CLI with no dependencies:
node find-symbol.mjs "convert date to utc"
node find-symbol.mjs --kind component "loading spinner"
node find-symbol.mjs --module src/redux "thunk"
The part that makes it cheap
Here's the bit I like.
That 5.5 MB graph reduces down to a 717 KB index of 873 symbols. Run a token counter over it and it's roughly 248,000 tokens of structural information about the codebase. The full graph is closer to 1.9 million.
None of it is ever loaded into a context window. Not once.
A search reads it from disk, ranks it in about 300 milliseconds, and prints ten lines.
That ratio, two million tokens on disk and ten lines in the answer, is the entire argument for building structure instead of stuffing more files into a prompt. It's also why this works fine for humans who don't have a context window at all.
Commit the index, gitignore the graph. And pretty-print it rather than minifying. It changes on every edit and turns up in most diffs, so a reviewer is going to see it. A readable diff is the difference between "fine, whatever" and "can we please delete this thing." If merge conflicts get annoying, a merge=union gitattribute helps.
Do you need embeddings for this?
Not to start. Embedding search does help when the query and the symbol share no vocabulary at all, and that case is real.
But a lexical scorer is fast, local, explainable, free to run, and tunable in an afternoon. No vector database, no model dependency, no API key, no monthly bill for a service that indexes 873 functions.
Add embeddings later, once you've collected actual searches where the ranking failed. Otherwise you're solving a problem you haven't met yet, which is a considerably more expensive hobby than it sounds.
Same answer for graph databases. My entire query surface is graph.edges.filter(...) run through node -e. Reach for Neo4j when you need multi-repo traversal or interactive exploration, not because a diagram had arrows in it.
Keeping it honest
An index that was accurate three months ago is a historical document. Regenerate it in one of these places, best first:
Editor or agent post-edit hook. Freshest, zero ceremony, but only covers people using that tool
Pre-commit. This is the one that keeps it correct for the whole team
Manual, before searching. Costs 13 seconds
CI on merge. Fail if regenerating produces a diff
Whatever you do, don't put regeneration on a sprint cadence. It goes stale during the sprint, which is precisely when the duplicates get written. You'd be building a tool that is reliably most wrong at the exact moment anyone needs it.
My post-edit hook is 25 lines, and the most important line in it is this:
} catch {
// Never disrupt the workflow. Swallow errors silently.
}
A background hook that can fail loudly will be switched off within a week, by you, irritably, mid-task, the third time it interrupts something. It either works invisibly or does nothing at all. Both outcomes are survivable. Popping up in someone's edit loop is not.
Where this doesn't help
Worth being upfront. The graph misses runtime dependency injection, string-based event names, dynamic imports, reflection, database relationships and external config.
The biggest gap is simpler: it only sees TypeScript. SQL, Python, templates, config files: all invisible, along with any coupling that runs through them. If a chunk of your logic lives in stored procedures, the graph is describing half your system while looking complete.
Treat it as a useful model, not an oracle.
Start smaller than this
You don't need everything above to get value. Here's the version that fits in a day:
Build a
ProgramIndex files and exported symbols
Resolve
importsandcallsWrite
classifyFilefor your foldersAdd one framework-specific edge, whichever coupling your team keeps asking about
Save as JSON, gitignore it, commit a reduced index
Add one command people run before writing shared code
Steps 1–4 are an afternoon. The rest is another one. On a 771-file tree the whole rebuild takes 13 seconds, so the loop while you're tuning detectors is quick.
Then watch what happens. Do people find existing helpers? Are fewer near-duplicate utilities landing? Which searches return junk?
The graph is only infrastructure. Its value comes entirely from the questions people actually ask it.
What I got out of it wasn't really the search command, in the end. It was that the codebase stopped feeling like something I was negotiating with. Changes I'd have approached carefully, I could now approach quickly, because I could see what was on the other side of them.
That's a small thing to say and a large thing to have.
Quick answers
Do I need a graph database? No. JSON and array filters go a long way.
How long does the build take? 13.1 seconds on 771 files. Fine on a save hook, too slow per keystroke.
Does this work for plain JavaScript? Partly. allowJs pulls .js files in, but without annotations the symbol resolution is much weaker.
Is this just an AST? No. An AST gives you syntax inside one file. The type checker resolves meaning across files, working out which declaration an identifier points at. That's the part that matters.
Should I use ts-morph instead? If you're writing codemods, yes, it's a much nicer API. I went with the raw Compiler API because the only import is typescript, which the project already had, so the script copies into another repo with no install step. That's the whole trade: a few hours of extra plumbing for zero new dependencies.
Next: handing this to an AI agent
Once your codebase is nodes and edges, search is only the first thing you can do with it. The same graph can decide what an agent should read before editing a function, which routes a refactor might touch, and whether a change just crossed an architecture boundary.
I measured that, including the case where it costs more than doing nothing:
Code Graphs for AI Coding Agents: Better Repository Context and Safer Refactoring
I'm Ashish Kr Jha, a software engineer. I build things in production and then have to live with them, which is the part that actually teaches you something. More at ashishkrjha.dev.
