Editor Setup for VS Code & Neovim
WebJs ships a TypeScript overlay (packages/core/index.d.ts and packages/core/src/component.d.ts) so any editor that speaks the TypeScript Language Server (tsserver) gets autocomplete, hover documentation, and type-checking for the framework APIs with zero build step.
VS Code, Cursor, Windsurf, VSCodium: install the webjs extension from the VS Marketplace or Open VSX (search "webjs"). It bundles the language-service plugin and registers it automatically (no tsconfig.json edit), and adds html / css template highlighting.
Neovim: install webjsdev/webjs.nvim via lazy.nvim ({ 'webjsdev/webjs.nvim', opts = {} }) for treesitter html / css / svg template highlighting, the :WebjsCheck diagnostics source, and an init_options helper for wiring the tsserver plugin into ts_ls (see the Neovim section below).
The rest of this page is for wiring the plugin by hand and for understanding what it does.
@webjsdev/intellisense is editor-only, not required for the framework to run. It is standalone as of @webjsdev/[email protected]: its own html-template parser drives all the in-template intelligence, with no Lit dependency.
Two ways the intelligence reaches your editor (you can have both; tsserver dedupes, so there's no conflict):
- From the app's
node_modules: the scaffold lists{ "name": "@webjsdev/intellisense" }intsconfig.json, and tsserver editors load it fromnode_modulesafternpm install. This gives the intelligence with no editor plugin installed (VS Code with "Use Workspace Version", Neovimts_ls, JetBrains). It does NOT provide template highlighting (a tsserver plugin can't). - From the editor plugin: the
webjsVS Code extension andwebjs.nvimbundle the plugin, so the intelligence works even beforenpm install, and they add the template highlighting thatnode_modulescan't.
This page covers two layers of intelligence:
- Type-safe component internals:
this.student: Studentinside the class. Works out of the box oncetsconfig.jsonis set up. - In-template intelligence: completions, diagnostics, go-to-definition, and hover for custom-element tags and bindings inside
html`…`templates. Provided by@webjsdev/intellisense.
There's also an optional standard-TypeScript convention for typing document.querySelector('student-card'), briefly covered at the end.
Prerequisites
- Node 24+ for the built-in TypeScript type-stripping (
process.features.typescript === 'strip'). The framework requires it viaengines. - TypeScript 5.8+ as a dev dependency (
npm i -D typescript). Needed for theerasableSyntaxOnlycompiler option that catches non-erasable syntax in the editor. - A
tsconfig.jsonin your app. The scaffold generates one.
tsconfig.json: baseline
The scaffold writes this file. Manual apps should match it:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"skipLibCheck": true,
"erasableSyntaxOnly": true,
"plugins": [
{ "name": "@webjsdev/intellisense" }
]
}
}
Key points:
moduleResolution: "NodeNext": required for the framework'sexportsmap to resolve correctly.allowImportingTsExtensions: true: lets you writeimport { x } from './foo.ts', matching how WebJs serves them.noEmit: true: TypeScript type-checks only. WebJs strips types at the runtime layer at import / request time (Node's built-in stripper, oramaroon Bun).erasableSyntaxOnly: true: rejects non-erasable TypeScript (enum,namespacewith values, parameter properties, legacy decorators). Required because Node's stripper only supports erasable TS. See the TypeScript page for the erasable equivalents.plugins: one entry.@webjsdev/intellisenseis standalone (no separatets-lit-pluginentry).
Layer 1: component internals (works everywhere)
Declare each property in the WebComponent({ ... }) factory. The factory types each field for you (no declare line), and the prop() helper narrows the type when a bare constructor is not specific enough (prop<Student>(Object)):
import { WebComponent, html, prop } from '@webjsdev/core';
import type { Student } from './student-types.ts';
export class StudentCard extends WebComponent({
student: prop<Student>(Object),
}) {
render() {
return html`<p>${this.student.name}</p>`;
}
}
StudentCard.register('student-card');
Inside the class, this.student is a real Student. Hover, autocomplete, and type-checking all work. this.requestUpdate, signal helpers (signal, computed) imported from @webjsdev/core, and all lifecycle hooks are typed by the framework's .d.ts overlay.
Why the factory replaces declare
The framework installs the reactive getter/setter on this via Object.defineProperty. A plain class-field initializer (student = undefined) would run after super() and overwrite that accessor, which is why the old pattern needed static properties plus a separate declare line. The WebComponent({ ... }) factory removes both: it derives the accessor type from the shape, so there is nothing to declare and no class-field initializer to clobber. Set a default via the default option or the constructor after super().
Layer 2: in-template intelligence
@webjsdev/intellisense parses the markup inside each html`…` template and contributes webjs-specific knowledge, all driven by the component's factory shape (the WebComponent({ ... }) call and any prop() helpers):
- Go-to-definition: F12 / Ctrl+Click on a WebJs tag jumps to its class; on an attribute / property / event name jumps to the class member; on a class name inside
html`class="…"`jumps to the matchingcss`…`rule. - Completions: reachable custom-element tag names after
<, and binding-aware attributes:.offers property names, plain /?offer the hyphenated attribute names (maxLengthbecomesmax-length),@eventis permissive. - Diagnostics:
<your-tag .count=${expr}>assignability-checkstypeof expragainst the prop's factory-declared type (also for plain attributes;@eventhandlers must be callable). Quoted@/./?bindings are flagged (the hole is dropped at SSR), as are expressionless.propbindings. A custom-element tag registered more than once across the project is underlined where it is registered (SSR keeps the last registration, the browser keeps the first, so a duplicate resolves inconsistently; theno-duplicate-tagwebjs checkrule is the matching CI gate). Static (non-interpolated) attribute text likemode="login"is deliberately not checked. - Hover: a tag shows its component class; an attribute / property / event shows its declared type.
There is deliberately no blanket "unknown tag / attribute" diagnostic: WebJs has no element type map, so flagging an unrecognised tag would false-positive on legitimate third-party custom elements.
Import-graph reachability
Completions and diagnostics are gated by reachability through the current file's import graph. A tag is "known here" only if the file that registers it is imported (directly or transitively) by the file you're editing. Otherwise the runtime would fail too, so the missing completion is the correct prompt to add the side-effect import. Go-to-definition is not gated, and neither is the duplicate-tag diagnostic (a collision is a runtime hazard whether or not the two files import each other, so it is checked project-wide).
| Tag state | Completions | Value type-check |
|---|---|---|
| Registered & reachable | offered | runs |
| Registered somewhere but not imported here | none | skipped |
| Not registered anywhere in the program | none | skipped |
Install (manual)
npm i -D typescript @webjsdev/intellisense
Then make sure the single plugin entry is in tsconfig.json (already in the baseline above):
{
"compilerOptions": {
"plugins": [{ "name": "@webjsdev/intellisense" }]
}
}
VS Code
Prefer the webjs extension (top of this page). To wire the plugin manually instead, tell VS Code to use your workspace's TypeScript so it picks up the plugin. Open any .ts file and:
Cmd/Ctrl+Shift+P → "TypeScript: Select TypeScript Version" → "Use Workspace Version"
Reload the window. The plugin is now active.
Neovim
Install webjsdev/webjs.nvim for treesitter template highlighting, :WebjsCheck, and :checkhealth webjs. It also gives you a helper to load the tsserver plugin without editing tsconfig.json:
require('lspconfig').ts_ls.setup({
init_options = require('webjs').with_tsserver_plugin(),
})
Otherwise, any LSP client that speaks tsserver loads the plugin automatically from tsconfig.json. The key is pointing the LSP at your workspace's node_modules/typescript so the plugin resolves.
nvim-lspconfig
-- lua/plugins/tsserver.lua
return {
'neovim/nvim-lspconfig',
config = function()
require('lspconfig').ts_ls.setup({
init_options = {
-- No extra config needed: tsserver picks up tsconfig.json's plugins
-- as long as typescript is installed in the workspace.
},
})
end,
}
typescript-tools.nvim (recommended for large projects)
Faster for large projects. Plugin-loading works the same way:
return {
'pmizio/typescript-tools.nvim',
dependencies = { 'nvim-lua/plenary.nvim', 'neovim/nvim-lspconfig' },
opts = {},
}
Verify the plugin loaded
In Neovim: :LspInfo should list ts_ls (or typescript-tools) attached to your .ts file. In VS Code: the bottom-right status bar shows the TypeScript version; click it and confirm it matches your workspace version.
Then write a deliberately wrong binding:
html`<student-card .student=${42}></student-card>`
// ^^^ squiggle: `number` is not assignable to property 'student' of type `Student`.
After upgrading the plugin
tsserver loads plugins on startup, so an editor restart is required to pick up new plugin code. In Neovim: :LspRestart. In VS Code: Cmd/Ctrl+Shift+P then "TypeScript: Restart TS Server".
Optional: typed DOM queries
If you want document.querySelector('student-card') to return StudentCard | null instead of Element | null, augment TypeScript's built-in HTMLElementTagNameMap inside your component file. This is a standard TypeScript pattern. With @webjsdev/intellisense active you no longer need this for tag/attribute intelligence inside html`…` templates. The augmentation is purely about typing DOM-query call sites.
Editor actions: quick reference
| Action | VS Code | Neovim |
|---|---|---|
| Hover type info | hover cursor | K |
| Go to definition | F12 or Ctrl+Click | gd |
| Find references | Shift+F12 | gr |
| Rename symbol | F2 | <leader>rn |
| Code actions | Ctrl+. | <leader>ca |
Verification walkthrough
After setup, open a component file and check each layer:
- Layer 1: hover
this.studentinsiderender()and expect(property) student: Student. Typethis.inside the class and expect autocomplete forstudent,requestUpdate,render, lifecycle hooks, etc. - Layer 2: write
<student-card>with the side-effect import in place, position the cursor inside<student-card |>, and the completions list includesstudent(and any other key of the factory shape). Type<student-card .student=${42}>and a WebJs diagnostic flags'number' is not assignable to property 'student' of type 'Student'. Then comment out theimport './student-card.ts'at the top of the file: completions disappear and the value-check goes silent (the missing import is now the surfaced problem). The plugin requires reachability so a missing import always surfaces.
If any layer misbehaves, the most common cause is tsserver using a different TypeScript install than your workspace's. In Neovim run :LspInfo; in VS Code click the TypeScript version in the status bar. Both should point inside your project's node_modules/.
See also
- Components: the full API surface.
- TypeScript: type safety end-to-end.
- Conventions: project layout + AI-agent workflow.