* 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.
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.
El diagrama del diálogo
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:
- Project references. Dividir el código en múltiples
tsconfig.jsonconreferencesycomposite: truepermite atsc, y por extensión atsserver, construir grafos parciales y reutilizar.tsbuildinfoentre ediciones. Sin esto, cualquier monorepo de cierta magnitud está condenado a esperas multisegundo. isolatedModules: truey, 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.--incrementaly la persistencia de.tsbuildinfo. El editor lo aprovecha de forma transparente si está configurado entsconfig.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
| Insight | Por qué importa |
|---|---|
| El editor sólo habla JSON-RPC | El conocimiento del lenguaje vive en otro proceso, reemplazable |
tsserver no es LSP puro | Necesitas un shim para clientes genéricos |
| Sincronización incremental es la regla | Sin deltas, la latencia es prohibitiva |
| Project references es la palanca principal | Reduce el grafo a recomprobar |
completionItem/resolve evita listas pesadas | Carga 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.