This article takes a practical, developer‑focused look at how TypeScript actually works behind your editor. It explains the role of tsserver and the Language Server Protocol (LSP), how editors get features like autocomplete, go‑to‑definition, and real‑time diagnostics, and how configured and inferred projects affect performance and type accuracy. You’ll also learn how typescript-language-server bridges LSP and tsserver, why defining stable boundary types and relying on type inference is critical in real‑world codebases, and how to avoid common anti‑patterns often seen in AI‑generated TypeScript. Concrete examples and best practices help you level up your TypeScript tooling mindset and development workflow.
Written by: Chia1104 CC BY-NC-SA 4.0
For most people writing TypeScript, the journey starts with installing VS Code and the TypeScript extension, then typing away and seeing autocomplete, error messages, and “Go to Definition” and thinking, “TypeScript is awesome.”

But for most developers, all of this is a black box: how does the editor know the types, and who is actually computing all those hints?
If you only treat TypeScript as “JavaScript that throws errors,” you end up missing a few important ideas: what tsserver is, and what the LSP is doing behind the scenes.
Most people know tsc as the TypeScript compiler, but all the autocomplete, go-to-definition, and refactor suggestions you get in modern IDEs and editors are powered by tsserver.
It's a standalone Node process that embeds the TypeScript compiler and language services, and talks to the editor over a JSON-based protocol.
Put simply, tsserver does a few core things:
Once you install the typescript package, you'll find the executable at node_modules/typescript/lib/tsserver.js. The editor usually starts it as a child process and then exchanges JSON messages over stdin/stdout.

Each request looks roughly like this:
seq, a fixed type: "request", a command name, and matching arguments.{
"seq": 1,
"type": "request",
"command": "open",
"arguments": { "file": "c:/path/to/file.ts" }
}Content-Length, followed by a JSON payload containing the results, such as completions, quick info, or diagnostics.The protocol's type definitions live in protocol.d.ts, and any client (editor or plugin) that speaks this protocol can turn UI actions (like pressing a shortcut or moving the cursor) into requests to tsserver.
In practice, things you do in the editor are translated into commands such as:
open / close: tell tsserver which files are currently being edited.change: notify tsserver that the text in a specific file/position has changed so it can update its in-memory view.completions, definition, rename, references: ask it to compute completions, go-to-definition, refactors, and more.This is also why TypeScript errors show up immediately even if you haven't saved the file yet: tsserver is looking at the in-memory version, not the old version on disk.
tsserver doesn't treat files as isolated units; it manages your codebase using the concept of “projects.”
The official docs describe three project types - see the “Project System” section: github
Configured Project
tsconfig.json.External Project
.csproj files.Inferred Project
tsconfig.json while walking up from a file's directory, it creates an inferred project for that file./// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";This project system is exactly why, in large codebases, you start to feel tsserver getting slower and more memory hungry. Splitting projects properly and narrowing the scope of each tsconfig often helps more than just complaining that “TypeScript is heavy.”
In the real world, there are two main integration patterns:
Speak the tsserver protocol directly
Wrap tsserver in an LSP server
typescript-language-server, which wraps tsserver: it speaks the Language Server Protocol (LSP) on the outside and the tsserver protocol internally.If tsserver is the core of TypeScript's language smarts, then the Language Server Protocol (LSP) is the standard that lets those smarts be shared across many different editors and IDEs.
LSP uses JSON-RPC to define a standard set of requests and responses so that any editor that speaks this protocol can work with any language server that implements it.
The key goal is simple: decouple editors from language tools.
This gives you nice properties like:
The LSP message format is built on top of JSON-RPC 2.0, and every operation is either a request, a response, or a notification. jsonrpc
Roughly speaking, the flow looks like this:
initialize request describing its capabilities, like whether it supports code actions, rename, and so on.textDocument/didOpen to the server with the full document content.textDocument/didChange, describing which range changed and what text was inserted.textDocument/didClose, allowing the server to free up resources.textDocument/completion: ask for completion items.textDocument/definition: ask for go-to-definition.textDocument/hover: ask for hover information.textDocument/rename: ask for a rename refactor.textDocument/publishDiagnostics notifications to report errors and warnings.The beauty of this setup is:
typescript-language-server as the BridgeAs mentioned earlier, tsserver speaks its own JSON protocol, not LSP.
To make TypeScript reusable across LSP-based editors, the community created typescript-language-server, whose job is to translate between LSP and the tsserver protocol.
You can think of its role like this:
initialize and textDocument/*.completions, definition, rename, etc.So in Neovim, Zed, or any other LSP-enabled editor, the TypeScript experience you see is still powered by the same tsserver — there's just an extra LSP adapter layer in between. zed
My editor is the client. TypeScript's language capabilities live in tsserver. LSP is the standardized protocol for talking to language servers, and typescript-language-server wraps tsserver as an LSP server so more tools can use it. github
With this mental model, when you tweak your toolchain, inspect language‑service logs, or debug weird behavior, you’re no longer just “mashing the restart button.” You can reason about whether the issue is in the LSP layer or in tsserver itself.
Once you understand tsserver and LSP, it's worth shifting the focus back to how you think when writing TypeScript.
Earlier, when talking about projects, we touched on a key TypeScript idea: inference. tsserver uses your code and configuration to infer a graph of types across the project, and then powers refactors, navigation, and diagnostics based on that graph.
This has a strong implication: you really shouldn't keep redefining types that can be inferred. I'd even argue this is important enough to treat as a “top rule” — including when you ask AI to generate TypeScript.
Let's walk through a simple API response example. Suppose we fetch a list from an API:
interface Product {
id: string;
name: string;
items: Product[];
}
const getProducts = async () => {
const products = await fetch("https://api.example.com/products").then(
(res) => res.json() as Promise<Product[]>
);
return products;
};We still need to define a stable boundary type up front.
const Products = () => {
const [products, setProducts] = useState<Product[]>([]);
useEffect(() => {
getProducts().then(setProducts);
}, []);
return (
<div>
{products.map((product, index, products) => (
<div key={product.id}>{product.name}</div>
))}
</div>
);
};
Here, we define a stable boundary type for the API call and for setState, so tsserver knows what products is. But once that's in place, the Array.map callback can simply rely on inference — TypeScript already knows the element type from products.
Now imagine we artificially redefine the type in the callback:
interface FakeProduct {
id: string;
items: FakeProduct[];
}
const Products = () => {
const [products, setProducts] = useState<Product[]>([]);
useEffect(() => {
getProducts().then(setProducts);
}, []);
return (
<div>
{products.map((product: FakeProduct) => (
<div key={product.id}>{product.name}</div>
))}
</div>
);
};
You'll immediately see an error: FakeProduct doesn't have a name field, so within the map callback, the compiler believes product is FakeProduct and blocks access to product.name.
I've seen this mistake a lot — both in real-world codebases and in AI-generated code: don't shadow or override types that can already be inferred from your existing source of truth.
any everywhere or overusing as assertions: both cut off inference and make tsserver's understanding (and your editor's hints) much less trustworthy.satisfies and as const to make inference more precise and catch issues like misspelled string literals instead of silently letting them through.