Skip to content

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" } in tsconfig.json, and tsserver editors load it from node_modules after npm install. This gives the intelligence with no editor plugin installed (VS Code with "Use Workspace Version", Neovim ts_ls, JetBrains). It does NOT provide template highlighting (a tsserver plugin can't).
  • From the editor plugin: the webjs VS Code extension and webjs.nvim bundle the plugin, so the intelligence works even before npm install, and they add the template highlighting that node_modules can't.

This page covers two layers of intelligence:

  1. Type-safe component internals: this.student: Student inside the class. Works out of the box once tsconfig.json is set up.
  2. 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 via engines.
  • TypeScript 5.8+ as a dev dependency (npm i -D typescript). Needed for the erasableSyntaxOnly compiler option that catches non-erasable syntax in the editor.
  • A tsconfig.json in 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's exports map to resolve correctly.
  • allowImportingTsExtensions: true: lets you write import { 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, or amaro on Bun).
  • erasableSyntaxOnly: true: rejects non-erasable TypeScript (enum, namespace with 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/intellisense is standalone (no separate ts-lit-plugin entry).

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 matching css`…` rule.
  • Completions: reachable custom-element tag names after <, and binding-aware attributes: . offers property names, plain / ? offer the hyphenated attribute names (maxLength becomes max-length), @event is permissive.
  • Diagnostics: <your-tag .count=${expr}> assignability-checks typeof expr against the prop's factory-declared type (also for plain attributes; @event handlers must be callable). Quoted @/./? bindings are flagged (the hole is dropped at SSR), as are expressionless .prop bindings. 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; the no-duplicate-tag webjs check rule is the matching CI gate). Static (non-interpolated) attribute text like mode="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 stateCompletionsValue type-check
Registered & reachableofferedruns
Registered somewhere but not imported herenoneskipped
Not registered anywhere in the programnoneskipped

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

ActionVS CodeNeovim
Hover type infohover cursorK
Go to definitionF12 or Ctrl+Clickgd
Find referencesShift+F12gr
Rename symbolF2<leader>rn
Code actionsCtrl+.<leader>ca

Verification walkthrough

After setup, open a component file and check each layer:

  1. Layer 1: hover this.student inside render() and expect (property) student: Student. Type this. inside the class and expect autocomplete for student, requestUpdate, render, lifecycle hooks, etc.
  2. Layer 2: write <student-card> with the side-effect import in place, position the cursor inside <student-card |>, and the completions list includes student (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 the import './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