Building a Language Server for EdgeJS in Three Days
Back in July I built Edge Language Tools: type safety and real editor tooling for EdgeJS templates. Squiggles on typos, autocomplete for props, hover types, go-to-definition into components, a CI checker, and generated types that make edge.render() calls compile-time checked. It went from "I wish" to a public repo with docs in three days, and a few days later Harminder Virk, the creator of AdonisJS and Edge, tweeted about it.
I never wrote it up here. The repo has a Story page with the design details, but this is the blog version: where the idea came from, what I stole from other ecosystems, how it actually got built, and the bugs that were worth the pain.
The itch
I love Edge. Coming from years of Twig and Blade, it is the template language that feels most like home in JavaScript land: the same @if / @each / @include vocabulary, mustaches for output, components with slots. And because it is JS-native, the expressions inside the mustaches are real JavaScript, not a bespoke mini-language.
It had one thing I could not stop scratching at. Nothing warns you when {{ user.nmae }} references a property that does not exist. You find out at runtime, in production, from a stack trace.
The people who could fix it had explicitly said no. The AdonisJS team's January 2024 post "Use TSX for your template engine" names the exact problem and routes around it: "If we want to have type safety, we would need to create a complete LSP from scratch, which is out of the scope of this project." A community proposal for typed props (edge-js/edge#160) had gone stale and auto-closed. The prior art was an official VS Code extension with TextMate highlighting only, a regex-based linter, and a Zed extension whose README said it was "waiting for the LSP" to exist.
So on a Sunday evening I opened a session with this, more or less verbatim:
why did they say they won't? should we just take a stab at it? it seems like a well defined enough problem right? to make agents successful at this we just need clear goals and ways to verify
That last clause turned out to be the whole methodology. More on that below.
What every other ecosystem already learned
The best part of arriving late is that everyone else has already made the mistakes. Before writing a line of code I had the research done on how six ecosystems solved (or failed to solve) typed templates:
| Ecosystem | Mechanism | Verdict |
|---|---|---|
| Twig | {% types %} tag (3.13, experimental); TwigStan infers from render call sites in batch CI | Annotation became official; inference never worked interactively |
| Blade | @props in components; tooling compiles Blade to PHP and reuses PHP analysis | Explicit interfaces won; plain view() bags stayed untyped |
| Rails | Strict locals: <%# locals: (title:, icon: nil) %> (7.1) | The most MVC-orthodox framework conceded that views need declared interfaces |
| Ember (Glint) | v1: hand-rolled LSP + central template registry. v2: ground-up rewrite on Volar.js | The registry was the #1 complaint; the hand-rolled LSP was unsustainable |
| Svelte / Vue | svelte2tsx / Volar generate virtual TS where template constructs become real control flow | The blueprint: let tsc do all the inference |
| templ (Go) / Askama (Rust) | The template is a typed function | Full checking for free, because the input is a declared type |
Three lessons fell out of that table:
- In-template declaration won everywhere it was tried. Central registries rot (Glint v1's greatest regret). Inferring from render call sites inverts the check: if callers define the truth, a caller passing garbage is by definition correct.
- Do not hand-roll the LSP. Volar.js, the framework under Vue, Astro, MDX and Glint 2, handles the entire editor side if you can produce one thing: a virtual TypeScript file with offset mappings back to the source.
- Emit real control flow and let tsc think. svelte2tsx turns
{#each items as item}into an actual loop soitem's type is inferred, not annotated. TypeScript is the type checker. You just need a translator.
The "complete LSP from scratch" estimate was priced against a tooling landscape that no longer exists. That was the bet.
The design
Edge has one decisive advantage over Twig and Blade here: its expressions are already JavaScript. So the type language for declarations can be actual TypeScript, in a comment that today's Edge ignores completely:
{{--
@types {
user: import('#models/user').User
items: string[]
}
--}}
<h1>{{ user.name }}</h1> {{-- typo in a prop? red squiggle --}}
@let(total = items.length * 2)
<p>{{ total }}</p> {{-- number, inferred, never declared --}}
@each(item in items)
<li>{{ item }}</li> {{-- item: string, inferred from items --}}
@end
The block is the template's function signature. Everything below it is inferred. Templates without a block stay unchecked, exactly like plain .js files in a TypeScript project, so adopting it can never break a render. That last part matters: Rails' strict locals are runtime-enforced and a missing local raises in production. Here the worst case is a squiggle you ignore.
Everything else is one pipeline with that block as the single source of truth:
The virtual file trick
The core generates, per template, a TypeScript module that means the same thing as the template, with every user expression copied byte-for-byte and its offsets recorded. Hover the highlights below to see how the two files line up:
TypeScript checks the file on the right. Every diagnostic inside a mapped segment is translated back to exact template coordinates. Three rules make it robust:
- Copy expressions verbatim, never rewrite them. A segment's generated text must byte-equal its source text. There is a round-trip property test enforcing this, and it is why diagnostics, hover and completions land precisely.
- Emit template constructs as real control flow.
@ifbecomesif(free narrowing),@eachbecomesfor..of(free inference),@elseon a loop becomes a sibling block, component slot bodies become nested blocks in the caller's scope. - Glue code must be error-proof and unmapped. Diagnostics from generated scaffolding are dropped, so the scaffolding had better be correct.
Cross-file checking rides the same rail. @component('components/user-card', { displayName: user.displayName }) resolves the target template, reads its @types, and checks the caller's props object against it. Edge's "supercharged" shorthand tags (@form.input(...) for components/form/input.edge) are a deterministic filename-to-tag conversion, so the checker just runs it backwards. The diagnostic lands on the caller's typo, where you would look.
In the editor, the round trip looks like this:
Closing the loop: typed render calls
A generated templates.d.ts maps every template path to its declared props. Here I hit a genuinely surprising finding: the obvious approach, augmenting Edge's class via declare module 'edge.js', is silently unsafe. Interface merging can only add overloads, never replace Edge's existing loose render(templatePath: string, state?: Record<string, any>). Wrong props just fall through to the loose signature and pass. I verified that failure mode explicitly, then shipped a wrapper type instead:
export type TypedEdge = Omit<Edge, 'render' | 'renderSync'> & {
render<K extends keyof EdgeTemplates>(templatePath: K, state: EdgeTemplates[K]): Promise<string>
render<S extends string>(templatePath: UnknownEdgeTemplate<S>, state?: Record<string, any>): Promise<string>
}
// one cast at setup
const edge = Edge.create() as unknown as TypedEdge
Omit strips the loose members so there is nothing to fall through to. Templates without @types stay callable with anything. Gradual adoption again.
And because the @types body accepts any TypeScript type expression, shared shapes need no new syntax: {{-- @types import('#models/user').User --}} gives a template live Lucid model types, resolved through the consuming app's tsconfig. Arguably better than Inertia's page-prop typing, which can only see the serialized shape.
How it was actually built
This is the part the docs don't cover. The project is 93 commits over three days, and I did not type most of them. I ran it the way I run most things now: a lead session that researched, planned and reviewed, and a team of named agents working in parallel on separate packages.
The thing that made that work was deciding, before any generator code existed, that the fixture corpus was the spec:
fixtures/typo-prop/input.edge # {{ user.nmae }} with @types declaring name
fixtures/typo-prop/diagnostics.json # [{ "messageIncludes": "nmae", "atText": "nmae" }]
atText anchors an expectation to the exact source substring the diagnostic must span. A diagnostic that lands one byte off fails the suite. Alongside it: snapshot tests of every generated virtual file, and the round-trip property test. Deterministic input, deterministic output, a failing suite as the definition of done. That is what "clear goals and ways to verify" meant in practice, and it is why agents could work unsupervised on their own packages without drifting.
The rough timeline:
- Sunday evening. Research first: the AdonisJS posts, the closed issue, every prior-art repo, and the six-ecosystem survey above. Then a grilling session to settle the open questions (comment syntax vs a new tag, the name
@typesinstead of@props, bare edge.js must work without Adonis). Then the kick-off: generator,edge-checkCLI, Volar server + VS Code extension, codegen, and a Zed extension, each built by its own agent against the shared fixtures. All five landed within about an hour of each other. - Sunday night. An audit agent crawled every page of edgejs.dev and Edge's source and produced a construct-by-construct coverage matrix. It found silent blind spots: component bodies were invisible to the checker,
@each/@elsefallbacks were dropped, unknown block tags leaked content into the wrong scope. All fixed, then a torture corpus of chained features, because every real bug so far had lived in the interactions. - Monday. Strict mode (an opt-in
requireTypesglob inpackage.json, so coverage can only ratchet up), a docs site, GitHub Pages, and a comment on the old issue #160 pointing at the proof of concept. - Tuesday. Tag completion on
@, hover docs on every tag, go-to-definition,@name/@descdoc headers, an examples section, a roadmap, and the teaser video at the top of this post, which an agent made with Remotion in about ten minutes.
By the numbers at the end of Tuesday: 5 packages, 2 editor extensions, 56 fixture scenarios, 185 tests, 93 commits, one deliberately untyped legacy page in the demo app to prove gradual adoption works.
My job in all that was direction and verification. Reading the coverage matrix and saying "you forgot stacks and slots". Opening Zed on my Mac and reporting "no red squigglies". Deciding that a dual @types() ... @end tag form we had built should be ripped out again to keep the upstream proposal minimal. And checking the docs like a user would, which is how the most expensive bug got found.
The bugs worth writing down
The ASI landmine. const { user } = state followed by (async () => {...})(). Without a leading semicolon, automatic semicolon insertion glues them into state(...), a call expression. Diagnostics went quietly wrong for several fixtures. Caught only because the corpus asserted exact offsets.
The script-scope leak. The prettiest bug of the project. In the editor, home.edge showed profile.edge's type for user. Virtual files had no imports or exports, so TypeScript treated them as scripts sharing one global scope, and every template's const user collided project-wide. The CLI never saw it because it checks each template in an isolated program; only the editor loads them all into one tsserver project. Fix: one line, export {}. The regression test simulates the editor scenario, which the per-fixture harness structurally could not catch.
Bun forks with Bun. The language server ships as a plain Node binary. The test suite, run under Bun, passed while the server was broken under real Node (Node's TypeScript strip-mode rejects parameter properties), because Bun's child_process.fork spawns Bun, which is more permissive. Verify the actual runtime path, not a lookalike.
The Zed sandbox saga. Wiring the server into Zed (running on my Mac, editing over SSH remote) burned more wall-clock than any feature. Zed's worktree.which() only searches the shell PATH, not node_modules/.bin, and it cannot read into node_modules at all because the worktree index excludes it. Three wrong fixes shipped before the evidence-backed one, and the durable lesson was procedural, not technical: speak raw LSP to the server and see the diagnostic yourself before telling anyone to try again.
What happened next
I posted the proof of concept on issue #160 on the Monday. On the Friday, this showed up:

The part he called out was not the language server. It was the Story page, the "why". Which is a good reminder that the research and the write-up are not the overhead around the project; for a proof of concept aimed at maintainers, they are the project. Nobody adopts a language server because the code is nice. They adopt it because the reasoning holds up.
Since then the repo has grown a couple of experiments that deserve their own posts: basecoat-edge, a typed Edge port of the Basecoat UI kit with a live type-safe playground, and an experimental @client package that extracts marked templates into tiny typed browser render functions, no Edge runtime in the bundle. The roadmap is honest about what is missing: slots have no contract yet, $props dynamic access is untyped, and the packages are not on npm. The endgame is convergence with the official extension, not competition with it.
The takeaway
The original itch needed no changes to Edge itself, no fork, no runtime cost, and no "complete LSP from scratch". Volar plus Edge's own lexer and parser plus roughly two thousand lines of generator glue.
The pattern generalizes shamelessly. Any template language whose expressions are close enough to a real language can get this treatment: declare the boundary once, compile constructs to honest control flow, copy expressions verbatim with offset maps, and let the host language's type checker do what it already does better than anything you would build.
I did not build a type checker. I built a translator, and borrowed the best type checker in the industry.
Repo: github.com/eduwass/edge-language-tools · Docs: eduwass.github.io/edge-language-tools
Read other posts →