* notes / typescript

El LSP de TypeScript: cómo tu editor entiende el código

Un viaje técnico por el Language Server Protocol aplicado a TypeScript: tsserver, JSON-RPC sobre stdio, sincronización incremental y las trampas que descubrí configurándolo en Neovim desde cero.

February 7, 20248 min read#typescript#lsp#neovim#editor#tooling

Durante mucho tiempo asumí que la inteligencia que muestra VS Code al sugerir un método o saltar a una definición era, de algún modo, magia interna del editor. Lo entendí cuando, cansado de un VS Code lento sobre un monorepo, decidí configurar TypeScript en Neovim a mano. No había paquete que ocultase la maquinaria: tuve que conectar un cliente LSP, lanzar tsserver, decidir qué capacidades anunciaría y depurar mensajes JSON-RPC sobre stdio. De aquella semana, agotadora pero esclarecedora, traigo una comprensión nueva: el editor casi no sabe nada de TypeScript; sólo conversa, en un idioma muy bien especificado, con quien sí sabe.

Contexto: qué problema resuelve el LSP

Antes del Language Server Protocol (publicado en 2016), cada editor reimplementaba para cada lenguaje sus propios analizadores, completadores, navegadores de símbolos y formateadores. El esfuerzo era cuadrático: M editores × N lenguajes = M·N integraciones. El LSP rompe esa simetría introduciendo un intermediario: un proceso aparte, el language server, que conoce el lenguaje en profundidad, y un language client dentro del editor que sólo necesita hablar JSON-RPC. Cualquier editor que implemente el cliente obtiene gratis cualquier servidor disponible.

En el caso de TypeScript, el servidor canónico es tsserver, distribuido como parte del propio paquete typescript desde mucho antes de la estandarización del LSP. Por razones históricas, tsserver habla un protocolo propio (similar al LSP pero no idéntico) y, para integrarse con clientes LSP genéricos, suele envolverse en un shim como typescript-language-server, que traduce los mensajes en ambos sentidos. Cabe destacar que VS Code usa directamente tsserver sin el shim, porque su cliente fue diseñado a medida; Neovim, Zed, Helix y Sublime, en cambio, pasan por el adaptador.

Editor(client LSP)JSON-RPCstdio / pipeContent-Length: Ntsserver(+ shim LSP)tsc API(checker)

Cada petición del editor recorre el bucle completo hasta el checker de TypeScript.

El shim traduce entre el protocolo histórico de tsserver y LSP estándar.

El handshake: initialize

Toda sesión LSP comienza con la misma ceremonia. El cliente lanza el servidor como subproceso y le envía una petición initialize con dos datos críticos: la raíz del workspace (usada para localizar tsconfig.json y package.json) y un objeto capabilities que declara qué funcionalidades soporta el cliente. Esta negociación es bidireccional: el servidor responderá con sus propias capacidades, y a partir de ese momento ambas partes se ciñen al subconjunto común.

Veamos la petición que enviaría Neovim, con los campos relevantes resaltados:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "processId": 48211,
    "rootUri": "file:///Users/alex/dev/monorepo",
    "capabilities": {
      "textDocument": {
        "synchronization": {
          "dynamicRegistration": true,
          "willSave": true,
          "didSave": true
        },
        "completion": {
          "completionItem": {
            "snippetSupport": true,
            "documentationFormat": ["markdown", "plaintext"]
          }
        },
        "definition": { "linkSupport": true },
        "publishDiagnostics": { "relatedInformation": true }
      },
      "workspace": {
        "configuration": true,
        "workspaceFolders": true,
        "didChangeWatchedFiles": { "dynamicRegistration": true }
      }
    },
    "initializationOptions": {
      "preferences": { "includeInlayParameterNameHints": "literals" }
    }
  }
}

La respuesta del servidor enumera, en serverCapabilities, lo que efectivamente puede ofrecer: por ejemplo, completionProvider, definitionProvider, hoverProvider, referencesProvider y, crucialmente, el modo de sincronización textual (textDocumentSync), que dicta si los cambios viajan completos o como deltas incrementales. Para un proyecto TypeScript de tamaño medio, los deltas son obligatorios: enviar el archivo entero a cada pulsación de tecla sería ruinoso.

Sincronización incremental: el corazón de la latencia

Tras initialize, el cliente notifica textDocument/didOpen con el contenido completo del archivo, generando un script snapshot del lado del servidor. A partir de ahí, cada cambio se transmite como textDocument/didChange con un array de rangos modificados:

{
  "jsonrpc": "2.0",
  "method": "textDocument/didChange",
  "params": {
    "textDocument": {
      "uri": "file:///Users/alex/dev/monorepo/src/index.ts",
      "version": 7
    },
    "contentChanges": [
      {
        "range": {
          "start": { "line": 12, "character": 14 },
          "end":   { "line": 12, "character": 14 }
        },
        "text": "."
      }
    ]
  }
}

El servidor aplica el delta sobre su copia y reinvoca al checker de TypeScript de forma incremental, aprovechando que ts.createLanguageService está diseñado precisamente para esto: reutiliza el Program previo y sólo recomprueba lo afectado por la edición. Por consiguiente, la latencia percibida depende sobre todo del grafo de tipos del proyecto, no del tamaño absoluto del código.

Una autocompletación, paso a paso

Tras escribir el punto en el ejemplo anterior, Neovim emite:

{
  "jsonrpc": "2.0",
  "id": 42,
  "method": "textDocument/completion",
  "params": {
    "textDocument": { "uri": "file:///Users/alex/dev/monorepo/src/index.ts" },
    "position": { "line": 12, "character": 15 },
    "context": { "triggerKind": 2, "triggerCharacter": "." }
  }
}

tsserver consulta al checker por la expresión user. (donde user es de tipo User) y devuelve una respuesta como esta:

{
  "jsonrpc": "2.0",
  "id": 42,
  "result": {
    "isIncomplete": false,
    "items": [
      {
        "label": "id",
        "kind": 5,
        "detail": "(property) User.id: string",
        "insertText": "id",
        "sortText": "11"
      },
      {
        "label": "email",
        "kind": 5,
        "detail": "(property) User.email: string",
        "insertText": "email",
        "sortText": "11"
      },
      {
        "label": "save",
        "kind": 2,
        "detail": "(method) User.save(): Promise<void>",
        "insertText": "save()",
        "sortText": "11"
      }
    ]
  }
}

Nótese que la respuesta no incluye documentación detallada de cada ítem. Esto es deliberado: para listas largas, calcular toda la información sería costoso. El cliente puede solicitar la versión enriquecida mediante completionItem/resolve sólo del ítem que el usuario está enfocando, una técnica de lazy loading que reduce drásticamente la carga.

Configuración mínima en Neovim

Reproduzco, simplificado, el snippet que terminé adoptando tras varias iteraciones. Asume typescript-language-server instalado globalmente:

local lspconfig = require('lspconfig')
 
lspconfig.ts_ls.setup({
  cmd = { 'typescript-language-server', '--stdio' },
  filetypes = { 'typescript', 'typescriptreact', 'javascript', 'javascriptreact' },
  root_dir = lspconfig.util.root_pattern('tsconfig.json', 'package.json', '.git'),
  init_options = {
    preferences = {
      includeInlayParameterNameHints = 'literals',
      includeInlayFunctionParameterTypeHints = true,
      includeInlayPropertyDeclarationTypeHints = true,
      importModuleSpecifierPreference = 'non-relative',
    },
    maxTsServerMemory = 8192,
  },
  on_attach = function(client, bufnr)
    vim.keymap.set('n', 'gd', vim.lsp.buf.definition, { buffer = bufnr })
    vim.keymap.set('n', 'K',  vim.lsp.buf.hover,      { buffer = bufnr })
    vim.keymap.set('n', '<leader>rn', vim.lsp.buf.rename, { buffer = bufnr })
    client.server_capabilities.documentFormattingProvider = false
  end,
})

Tres decisiones son menos triviales de lo que parecen. Primera, maxTsServerMemory = 8192 evita el OOM al que tsserver es propenso en monorepos grandes; el valor por defecto, 3 GB, se queda corto cuando el grafo de tipos supera el medio millón de símbolos. Segunda, deshabilitar documentFormattingProvider cede el formateo a Prettier o Biome: usar a tsserver para esto causaba conflictos sutiles. Tercera, el root_dir admite .git como fallback porque algunos paquetes del monorepo carecían de tsconfig.json local.

Performance: las tres palancas que de verdad importan

Si tsserver responde con retraso, el ajuste lúcido pasa por tres palancas, en orden de impacto descendente:

  1. Project references. Dividir el código en múltiples tsconfig.json con references y composite: true permite a tsc, y por extensión a tsserver, construir grafos parciales y reutilizar .tsbuildinfo entre ediciones. Sin esto, cualquier monorepo de cierta magnitud está condenado a esperas multisegundo.
  2. isolatedModules: true y, en general, escribir código que no dependa de inferencia cross-file. Cuanto más localmente se pueda chequear un archivo, más rápido se invalida y rechequea.
  3. --incremental y la persistencia de .tsbuildinfo. El editor lo aprovecha de forma transparente si está configurado en tsconfig.json.

Cabe destacar, asimismo, que tsserver carga un proyecto por root detectado. Si abres una carpeta con seis tsconfig.json, levantará potencialmente seis instancias en memoria. Para mitigarlo existen estrategias como tsserver.useTypeScriptVersionInProject, que reutiliza versiones, o el más reciente @typescript/native-preview, una implementación del compilador en Go que promete reducir tiempos hasta diez veces. En mi caso, migrar a project references redujo los tiempos de respuesta de 2,5 s a 350 ms; el resto fueron mejoras marginales.

Aprendizajes que me llevo

InsightPor qué importa
El editor sólo habla JSON-RPCEl conocimiento del lenguaje vive en otro proceso, reemplazable
tsserver no es LSP puroNecesitas un shim para clientes genéricos
Sincronización incremental es la reglaSin deltas, la latencia es prohibitiva
Project references es la palanca principalReduce el grafo a recomprobar
completionItem/resolve evita listas pesadasCarga perezosa de documentación

Closure

Configurar TypeScript en Neovim a mano me devolvió la noción, fácil de perder cuando uno vive dentro de VS Code, de que la inteligencia editorial moderna es, ante todo, un acuerdo de protocolo. El editor no entiende el código: confía en otro proceso que sí lo entiende y le pregunta con sintaxis prefijada. Ese desacoplamiento, lejos de ser un detalle de implementación, es lo que hace posible que tu próximo editor (Zed, Helix, alguno aún por nacer) funcione el primer día con cualquier lenguaje serio. No es magia: son mensajes JSON, bien definidos y bien tipados, viajando por stdio.