# TypeScript and ESLint (multiple language servers)

Canonical HTML: [TypeScript and ESLint (multiple language servers)](https://getred.dev/docs/languages/typescript-eslint). Website source: `b212874eefaf200143affa21c770fbb8855a4f5c`. Documented Red release: v0.9.1. Installer source: `b86a2ca4b92e9547d3b174910c82db8a259f5171`.

Attach TypeScript and ESLint language servers to the same buffer, keep Prettier formatting, and troubleshoot multi-server setups.

<a id="goal-and-required-red-version"></a>

## Goal and required Red version

> **Available in Red v0.9.0 and later**
>
> Use **Red v0.9.0 or later** to attach multiple language servers to a single document. In Red v0.8.2 and earlier, only one matching server attaches to each file.

This guide configures `typescript-language-server` and `vscode-eslint-language-server` to work together in the same `.ts`, `.tsx`, `.js`, or `.jsx` buffer while keeping Prettier as the external formatter. TypeScript serves completion, hover, signature help, symbols, and go-to-definition; ESLint publishes lint diagnostics and rule code actions alongside TypeScript's own diagnostics and quick fixes.

| Layer                  | Where it lives                                                    | Responsibility                                                                                                                           |
| ---------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| Embedded in Red        | Red binary                                                        | Tree-sitter syntax support for JS, JSX, TS, and TSX, plus a default `typescript` server definition                                       |
| Server executables     | Your `PATH`                                                       | `typescript-language-server` and `vscode-eslint-language-server` installed separately from Red                                           |
| Red user configuration | `~/.config/red/config.toml` or `$XDG_CONFIG_HOME/red/config.toml` | ESLint server registration, ESLint `workspace/configuration` settings, and optional external formatter definitions                       |
| Project workspace      | Project directory                                                 | `package.json`, local `node_modules` (`eslint`, `typescript`, `prettier`), `tsconfig.json`, `eslint.config.js`, and `prettier.config.js` |

Red does not automatically discover or register ESLint from a project's `eslint.config.js` or `package.json`. Once matching servers are configured in `config.toml`, Red attaches all of them automatically without an extra multi-LSP flag. Each server's `root_markers` only select the workspace root directory passed to that server; they do not install or register a server.

<a id="install-the-required-tools"></a>

## Install the required tools

Keep language-server binaries on `PATH` separate from the packages installed inside your project's `node_modules`.

### 1. Install the language-server executables on PATH

Install `typescript-language-server`, `typescript`, and `vscode-langservers-extracted` (which provides `vscode-eslint-language-server`):

```shell
npm install -g typescript-language-server@6.0.1 typescript@6.0.3 vscode-langservers-extracted@4.8.0
which typescript-language-server
which vscode-eslint-language-server
typescript-language-server --version
```

Installing `typescript` alone provides `tsc` and `tsserver`, not the `typescript-language-server` executable that Red launches.

### 2. Install project dependencies

`vscode-eslint-language-server` loads ESLint from the project's local `node_modules`, and `typescript-language-server` uses the workspace TypeScript compiler when present. Inside your project directory, install the compiler, linter, and formatter:

```shell
npm install --save-dev eslint@10.4.1 @eslint/js@10.0.1 typescript-eslint@8.60.1 typescript@6.0.3 prettier@3.8.3
```

> **Tested compatibility baseline**
>
> The steps in this guide were verified against the v0.9.0 implementation (`bf2c2039d`) on Node.js `v24.22.0` with `vscode-langservers-extracted` `4.8.0`, `typescript-language-server` `6.0.1`, TypeScript `6.0.3`, ESLint `10.4.1`, `@eslint/js` `10.0.1`, `typescript-eslint` `8.60.1`, and Prettier `3.8.3`. Other recent versions that use stdio LSP and ESLint flat configs follow the same setup, though diagnostic text and rule actions can vary across ESLint and plugin releases.

<a id="create-a-small-typescript-eslint-project"></a>

## Create a small TypeScript + ESLint project

If you want a self-contained project to verify the setup before opening a larger repository, create a directory with the following files and install the devDependencies above.

`package.json`:

```json
{
  "name": "red-ts-eslint-demo",
  "private": true,
  "type": "module",
  "devDependencies": {
    "@eslint/js": "10.0.1",
    "eslint": "10.4.1",
    "prettier": "3.8.3",
    "typescript": "6.0.3",
    "typescript-eslint": "8.60.1"
  }
}
```

`tsconfig.json`:

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noUnusedLocals": true
  },
  "include": ["src"]
}
```

`eslint.config.js`:

```javascript
import js from '@eslint/js';
import { defineConfig } from 'eslint/config';
import ts from 'typescript-eslint';

export default defineConfig(
  js.configs.recommended,
  ts.configs.recommended,
);
```

`prettier.config.js`:

```javascript
export default {
  singleQuote: true,
  semi: true,
};
```

`src/index.ts`:

```typescript
export const test = 'variable test';
const unusedTest = 'unused variable test';
```

Confirm that ESLint reports `@typescript-eslint/no-unused-vars` on `src/index.ts` from the terminal:

```shell
npx eslint src/index.ts
```

<a id="configure-red"></a>

## Configure Red

Red reads its user configuration from `$XDG_CONFIG_HOME/red/config.toml` when `XDG_CONFIG_HOME` is set and non-empty, or `~/.config/red/config.toml` otherwise. Because Red already embeds a default `typescript` server definition for `.ts`, `.tsx`, `.js`, `.mjs`, `.cjs`, and `.jsx`, the primary setup keeps that built-in TypeScript server and adds ESLint plus Prettier:

```toml
[formatting]
provider = "auto"

[languages.typescript.formatter]
name = "Prettier"
command = "prettier"
args = ["--stdin-filepath", "{file}"]
root_markers = ["prettier.config.js", "package.json", ".git"]

[lsp.servers.eslint]
command = "vscode-eslint-language-server"
args = ["--stdio"]
documents = [
  { language_id = "javascript", file_extensions = ["js", "mjs", "cjs"] },
  { language_id = "javascriptreact", file_extensions = ["jsx"] },
  { language_id = "typescript", file_extensions = ["ts"] },
  { language_id = "typescriptreact", file_extensions = ["tsx"] },
]
root_markers = ["eslint.config.js", "package.json", ".git"]

[lsp.servers.eslint.settings.""]
validate = "on"
run = "onType"
packageManager = "npm"
workingDirectory = { mode = "auto" }
nodePath = ""
useESLintClass = false
quiet = false
options = {}
experimental = {}
format = false
onIgnoredFiles = "off"
rulesCustomizations = []
codeActionOnSave = { mode = "all" }
problems = { shortenToSingleLine = false }

[lsp.servers.eslint.settings."".codeAction]
disableRuleComment = { enable = true, location = "separateLine", commentStyle = "line" }
showDocumentation = { enable = true }
```

The `[lsp.servers.eslint.settings.""]` and `[lsp.servers.eslint.settings."".codeAction]` tables are required by `vscode-eslint-language-server`. After initialization, the server sends a `workspace/configuration` request with `section = ""`; Red returns the contents of `settings.""`. Without that table, Red replies with `null` and the ESLint server does not validate buffers or offer rule-disable code actions.

### Merge into an existing config.toml safely

- **Avoid duplicate TOML table headers.** If your `config.toml` already has `[formatting]`, `[languages.typescript.formatter]`, or `[lsp.servers.eslint]`, merge the keys into the existing table instead of adding a second header with the same name.
- **Keep the built-in TypeScript server unless you need to customize it.** Omit `[lsp.servers.typescript]` so Red uses its embedded definition. Writing a partial `[lsp.servers.typescript]` table replaces the built-in entry as a unit and drops any omitted fields or extensions.

### Advanced: override the built-in TypeScript server

If you need to customize `typescript-language-server` arguments, environment variables, or root markers, include the complete definition so you preserve Red's default selectors (including `.mjs` and `.cjs`) and root markers:

```toml
[lsp.servers.typescript]
command = "typescript-language-server"
args = ["--stdio"]
documents = [
  { language_id = "javascript", file_extensions = ["js", "mjs", "cjs"] },
  { language_id = "javascriptreact", file_extensions = ["jsx"] },
  { language_id = "typescript", file_extensions = ["ts"] },
  { language_id = "typescriptreact", file_extensions = ["tsx"] },
]
root_markers = ["package.json", "tsconfig.json", "jsconfig.json", ".git"]
```

Validate your configuration from the terminal:

```shell
red --check-config
```

Restart Red after changing server definitions or installing server executables. For changes limited to unified language definitions, such as external formatters, you can reload those definitions inside Red:

```red-command
:languages reload
```

<a id="use-the-right-language-ids-and-selectors"></a>

## Use the right language IDs and selectors

Red uses two distinct sets of identifiers for JavaScript and TypeScript files: LSP `language_id` values sent to language servers in `textDocument/didOpen`, and Red's own `[languages.ID]` table names used for syntax and external formatters.

| Extension             | LSP `documents` `language_id` | Red `[languages.ID]` key |
| --------------------- | ----------------------------- | ------------------------ |
| `.js`, `.mjs`, `.cjs` | `javascript`                  | `javascript`             |
| `.jsx`                | `javascriptreact`             | `jsx`                    |
| `.ts`                 | `typescript`                  | `typescript`             |
| `.tsx`                | `typescriptreact`             | `tsx`                    |

Keep separate entries in `documents` for each extension rather than mapping `language_id = "typescriptreact"` to `["js", "jsx", "ts", "tsx"]`. Each attached server receives the `language_id` from the matching selector entry when a file opens.

If you also format `.tsx`, `.js`, and `.jsx` files with Prettier, add matching formatter tables under Red's language keys (`[languages.tsx.formatter]`, `[languages.javascript.formatter]`, and `[languages.jsx.formatter]`) using the same Prettier command and arguments.

<a id="how-red-routes-requests-across-attached-servers"></a>

## How Red routes requests across attached servers

- **Attachment and lifecycle:** Every configured server whose `documents` selector matches the file attaches to the buffer. Each attached server receives open, change, save, and close notifications with its own `language_id`, workspace root, and document version counter.
- **Diagnostics:** Push and pull diagnostics remain scoped to the server that produced them and are combined for display. An empty diagnostic report, server reload, or crash clears only that server's diagnostics.
- **Code actions:** Pressing `Space .` or `Space c a` queries every attached server that advertises `codeActionProvider`, sending each server only the diagnostics it owns. Red merges the returned actions into one picker and routes the selected edit or command back to its originating server.
- **Single-result requests:** Completion, hover, go-to-definition, references, document and workspace symbols, signature help, inlay hints, and rename go to the first ready attached server that advertises the required capability. When multiple attached servers advertise the same capability, Red uses the server configuration name (`[lsp.servers.NAME]`) in alphabetical order as the tie-breaker. Because `eslint` does not advertise hover, completion, definition, or symbol providers, those requests go to `typescript` even though `eslint` sorts first alphabetically.

<a id="verify-typescript-and-eslint-in-the-same-buffer"></a>

## Verify TypeScript and ESLint in the same buffer

1. **Open the file.**

   From the project directory, run `red -r . src/index.ts`.

2. **Check TypeScript hover.**

   Move to `test` on line 1 (`w` `w`) and press `K`. Red displays the hover popup from `typescript-language-server` (`const test: "variable test"`). Press `Esc` to close the popup.

3. **Check TypeScript completion.**

   Press `G` then `o` to open a new line, and type `test.` (or press `Ctrl-Space`). String members from `typescript-language-server` such as `slice`, `trim`, and `length` appear in the completion menu. Press `Esc` and `u` to undo the test line.

4. **Check combined diagnostics.**

   Move to `unusedTest` on line 2 (`gg` `j` `w`) and press `D` (or press `Space d` to open the workspace diagnostics picker). Both diagnostics appear for `unusedTest`: ESLint's `'unusedTest' is assigned a value but never used.` (`@typescript-eslint/no-unused-vars`) and TypeScript's `'unusedTest' is declared but its value is never read.` (`6133`).

<a id="apply-an-eslint-code-action"></a>

## Apply an ESLint code action

With the cursor on `unusedTest` on line 2, press `Space .` or `Space c a`. The code-action picker lists actions from both attached servers, including ESLint's rule actions and TypeScript's quick fixes.

Choose **Disable @typescript-eslint/no-unused-vars for this line** and press `Enter`. Red applies the workspace edit from `vscode-eslint-language-server`:

```typescript
export const test = 'variable test';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const unusedTest = 'unused variable test';
```

ESLint's `@typescript-eslint/no-unused-vars` error clears immediately, while TypeScript's own unused-variable diagnostic on `unusedTest` remains until the variable is read or removed.

<a id="keep-prettier-formatting"></a>

## Keep Prettier formatting

External formatters remain independent of language-server attachments. With `formatting.provider = "auto"` (the default) and `[languages.typescript.formatter]` configured for Prettier, pressing `Space f` runs Prettier instead of formatting through either language server. Saving with `:w` also formats when `formatting.on_save = true`; preserve the user's existing preference.

Red looks for `prettier` in the project's `node_modules/.bin` before searching `PATH`, and passes the current buffer over stdin with `--stdin-filepath {file}`. Setting `format = false` under `[lsp.servers.eslint.settings.""]` also prevents ESLint from advertising LSP formatting if Red ever falls back to LSP formatting.

To verify, change single quotes to double quotes on line 1 (`export const test = "variable test";`) and press `Space f`. Red rewrites the line to single quotes and reports `formatted with Prettier`.

<a id="troubleshoot-common-setup-failures"></a>

## Troubleshoot common setup failures

| Symptom                                                                  | Cause and fix                                                                                                                                                                                                                                                                                                                                                                                                             |
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| TypeScript works, but ESLint diagnostics never appear                    | Confirm `which vscode-eslint-language-server` succeeds on `PATH` (note that `~` is not expanded in `command`). When ESLint cannot start, Red isolates the failure so TypeScript completion, hover, and diagnostics keep working; check `Space m` (`:messages`) and `red.log` in your Red configuration directory. After installing a previously missing executable, restart Red so the cached startup failure is cleared. |
| `red --check-config` reports an error or LSP is disabled                 | Duplicate TOML headers such as two `[lsp.servers.eslint]` tables make the file malformed (`CFG002`) and start Red in its fail-closed fallback profile with LSP disabled. An invalid field inside one server table reports `CFG102` and disables only that server. Fix the reported line and re-run `red --check-config`.                                                                                                  |
| `.tsx` or `.jsx` files miss diagnostics, or `.ts` files parse as JSX     | Check the `documents` array on both servers. Map `.ts` to `typescript`, `.tsx` to `typescriptreact`, `.js`/`.mjs`/`.cjs` to `javascript`, and `.jsx` to `javascriptreact`.                                                                                                                                                                                                                                                |
| ESLint server starts, but reports no diagnostics or code actions         | Run `npx eslint src/index.ts` in the project root to verify local `node_modules/eslint` and `eslint.config.js` outside Red. Then confirm `[lsp.servers.eslint.settings.""]` and `[lsp.servers.eslint.settings."".codeAction]` are present in `config.toml` so `workspace/configuration` does not return `null`.                                                                                                           |
| TypeScript completion and hover stop working after editing `config.toml` | A `[lsp.servers.typescript]` table in `config.toml` replaces Red's embedded `typescript` server entry in full, and a `[languages.typescript.lsp]` block without `server = "eslint"` also writes to the `typescript` server key. Remove your custom `[lsp.servers.typescript]` block to restore Red's built-in default, or use the complete override shown in the advanced section above.                                  |

<a id="use-the-same-pattern-for-other-language-servers"></a>

## Use the same pattern for other language servers

You can attach any combination of language servers to the same file type by giving each server its own `[lsp.servers.NAME]` table with overlapping `file_extensions` or `filenames` selectors:

- Diagnostics from all attached servers merge automatically while keeping per-server ownership.
- Code actions aggregate across every attached server that advertises `codeActionProvider`.
- Single-result requests go to the first ready attached server that advertises the capability, using the server key name (`NAME`) in alphabetical order as a tie-breaker when more than one server supports the same request. Red does not guess a universally “best” server beyond capability checks and configuration-name order, so name server keys intentionally or disable overlapping capabilities in a secondary server's settings when two servers advertise the same single-result feature.

[Return to Language tools](https://getred.dev/docs/languages.md)
