The Language Service and tsserver: Powering IDE Experiences
Prerequisites
- ›Articles 1-5: Complete compiler pipeline understanding
- ›Familiarity with the Language Server Protocol (LSP) concept
- ›Understanding of how editors communicate with language servers
The Language Service and tsserver: Powering IDE Experiences
Every time you see a red squiggle, get an auto-completion, navigate to a definition, or apply a quick fix in your editor, you're using the code we'll explore in this final article. The TypeScript language service and tsserver form the bridge between the compiler core and the editor experience — wrapping the parsing, binding, and type-checking pipeline with IDE-oriented APIs and exposing them over a JSON protocol.
This layer adds roughly 40,000 lines of code on top of the compiler core, organized into two distinct layers: the LanguageService (a programmatic API) and the tsserver process (a wire-protocol server). Understanding this architecture explains how TypeScript achieves near-instant editor responsiveness while running a full type checker.
The LanguageService Interface and createLanguageService
The LanguageService interface defines the comprehensive API surface for editor features:
export interface LanguageService {
getSyntacticDiagnostics(fileName: string): DiagnosticWithLocation[];
getSemanticDiagnostics(fileName: string): Diagnostic[];
getSuggestionDiagnostics(fileName: string): DiagnosticWithLocation[];
getCompletionsAtPosition(fileName, position, options?): CompletionInfo;
getCompletionEntryDetails(fileName, position, name, ...): CompletionEntryDetails;
getDefinitionAtPosition(fileName, position): DefinitionInfo[];
getTypeDefinitionAtPosition(fileName, position): DefinitionInfo[];
findReferences(fileName, position): ReferencedSymbol[];
getRenameInfo(fileName, position, preferences?): RenameInfo;
getSignatureHelpItems(fileName, position, options?): SignatureHelpItems;
getQuickInfoAtPosition(fileName, position): QuickInfo;
getApplicableRefactors(fileName, positionOrRange, ...): ApplicableRefactorInfo[];
getCodeFixesAtPosition(fileName, start, end, errorCodes, ...): CodeFixAction[];
// ... 50+ more methods
}
createLanguageService() wraps a LanguageServiceHost — an abstraction over the file system and project configuration — and creates a Program that's kept in sync with the host's file versions. Each time you call a language service method, it calls synchronizeHostData() to ensure the internal Program reflects the latest file changes.
classDiagram
class LanguageServiceHost {
+getScriptFileNames(): string[]
+getScriptVersion(fileName): string
+getScriptSnapshot(fileName): IScriptSnapshot
+getCompilationSettings(): CompilerOptions
+getCurrentDirectory(): string
}
class LanguageService {
+getCompletionsAtPosition()
+getSemanticDiagnostics()
+getDefinitionAtPosition()
+findReferences()
+getCodeFixesAtPosition()
}
class Program {
+getSourceFiles()
+getTypeChecker()
+emit()
}
LanguageServiceHost <-- LanguageService : reads from
LanguageService --> Program : creates/manages
Program --> TypeChecker : creates on demand
The LanguageServiceHost design is key to how TypeScript supports multiple embeddings — VS Code's TypeScript extension, the tsserver process, webpack's ts-loader, and programmatic consumers all implement LanguageServiceHost differently, but they all get the same language service capabilities.
Tip: If you're building a tool that needs TypeScript's type information (a linter, a code generator, a documentation tool), implement
LanguageServiceHostand callcreateLanguageService(). You'll get the full power of the checker without dealing with tsserver's protocol.
Completions, Diagnostics, and Navigation
The Completions Engine
The auto-complete engine in src/services/completions.ts is 6,173 lines — the single largest feature implementation in the services layer. Given a cursor position, it must determine:
- What kind of completion context? — Member access (
foo.), string literal (import path), JSX attribute, type position, statement position, etc. - What symbols are in scope? — Using the checker's
getSymbolsInScope()for identifier completions, orgetPropertiesOfType()for member access - How to rank and filter results — Fuzzy matching, type-based relevance, recency of use, label details
sequenceDiagram
participant Editor
participant LS as LanguageService
participant Comp as completions.ts
participant TC as TypeChecker
Editor->>LS: getCompletionsAtPosition(file, pos)
LS->>Comp: getCompletions(sourceFile, position, ...)
Comp->>Comp: Determine completion context
alt Member access (foo.|)
Comp->>TC: getTypeAtLocation(foo)
Comp->>TC: getPropertiesOfType(type)
else Identifier position
Comp->>TC: getSymbolsInScope(location)
else Import path
Comp->>Comp: Search file system / package.json
end
Comp->>Comp: Filter, rank, format entries
Comp-->>Editor: CompletionInfo { entries: [...] }
Node Augmentation via Declaration Merging
One of the most creative patterns in the codebase is how the services layer adds convenience methods to AST nodes. In src/services/types.ts, you'll find:
declare module "../compiler/types.js" {
export interface Node {
getSourceFile(): SourceFile;
getChildCount(sourceFile?: SourceFile): number;
getChildAt(index: number, sourceFile?: SourceFile): Node;
getChildren(sourceFile?: SourceFile): readonly Node[];
getStart(sourceFile?: SourceFile, includeJsDocComment?: boolean): number;
getFullStart(): number;
getEnd(): number;
getWidth(sourceFile?: SourceFileLike): number;
getFullWidth(): number;
getText(sourceFile?: SourceFile): string;
getFirstToken(sourceFile?: SourceFile): Node | undefined;
getLastToken(sourceFile?: SourceFile): Node | undefined;
forEachChild<T>(cbNode: (node: Node) => T | undefined, ...): T | undefined;
}
}
This uses TypeScript's own declaration merging feature to retroactively add methods to the compiler's Node interface. The implementations are attached to Node.prototype at runtime in the services layer. The compiler core itself never depends on these methods — they exist purely for the convenience of service-layer code and external consumers of the public API.
Diagnostics Flow
Diagnostics flow through the service layer in three tiers:
- Syntactic diagnostics — fast, from the parser, no type checking needed
- Semantic diagnostics — from the checker, requires full type checking
- Suggestion diagnostics — non-error hints for potential improvements
The service layer caches diagnostics per-file and invalidates them when file versions change. For editor scenarios, syntactic diagnostics are returned first (instant feedback), followed by semantic diagnostics (which may take longer if the checker needs to do work).
Codefixes and Refactorings
The services layer includes two extensible registries of automated code actions:
Code Fixes (70+ implementations)
The src/services/codefixes/ directory contains over 70 individual code fix providers, each targeting specific error codes:
| Code Fix | Triggered By |
|---|---|
importFixes.ts |
"Cannot find name 'X'" |
fixSpelling.ts |
"Property 'X' does not exist. Did you mean 'Y'?" |
fixClassIncorrectlyImplementsInterface.ts |
"Class incorrectly implements interface" |
fixMissingAsync.ts |
Await used in non-async function |
addMissingAwait.ts |
Promise used where value expected |
fixStrictClassInitialization.ts |
Property not initialized in constructor |
convertToTypeOnlyImport.ts |
Import used only as type |
Each provider registers itself with a registerCodeFix() call, specifying which error codes it handles. When the editor requests code fixes for a diagnostic, the registry dispatches to the matching provider, which analyzes the error context and produces TextChange objects describing the fix.
Refactorings (16 providers)
The src/services/refactors/ directory contains refactoring providers:
extractSymbol.ts— Extract function / Extract constantmoveToFile.ts/moveToNewFile.ts— Move declarations between filesconvertParamsToDestructuredObject.ts— Convert positional parameters to named objectconvertImport.ts— Switch between named imports and namespace importsinlineVariable.ts— Inline a variable at all reference sitesconvertToOptionalChainExpression.ts— Convert&&chains to?.
flowchart TD
Editor["Editor: 'Refactor...'"] --> LS["getApplicableRefactors(file, range)"]
LS --> Registry["Refactoring Registry"]
Registry --> R1["extractSymbol"]
Registry --> R2["moveToFile"]
Registry --> R3["convertImport"]
Registry --> RN["...16 total"]
R1 --> Check["Is refactoring applicable<br/>at this position?"]
Check -->|Yes| Available["Return refactoring info"]
Check -->|No| Skip["Skip"]
Available --> Editor2["User selects refactoring"]
Editor2 --> Apply["getEditsForRefactor()"]
Apply --> Edits["TextChange[] across files"]
tsserver: Protocol, Session, and Project Management
The tsserver process is what editors actually communicate with. It's a Node.js process that reads JSON requests from stdin and writes JSON responses to stdout.
The Wire Protocol
src/server/protocol.ts defines the CommandTypes enum — every request type the server handles:
export const enum CommandTypes {
CompletionInfo = "completionInfo",
CompletionDetails = "completionEntryDetails",
Definition = "definition",
DefinitionAndBoundSpan = "definitionAndBoundSpan",
Implementation = "implementation",
References = "references",
Rename = "rename",
Geterr = "geterr",
Format = "format",
Configure = "configure",
Open = "open",
Close = "close",
Change = "change",
NavBar = "navbar",
// ... 50+ more
}
This is not LSP (Language Server Protocol) — it's TypeScript's own protocol, predating LSP. VS Code's TypeScript extension translates between LSP and this protocol. Some editors (like older Visual Studio) use this protocol directly.
The Session Class
src/server/session.ts contains the Session class that dispatches incoming requests. It maintains a map from command names to handler functions, each of which:
- Extracts parameters from the request
- Converts line/column positions to internal offsets
- Calls the appropriate
LanguageServicemethod(s) - Converts internal results back to protocol response format
The ProjectService
src/server/editorServices.ts contains ProjectService — the workspace manager. A single tsserver process can manage multiple projects simultaneously:
flowchart TD
PS["ProjectService"] --> CP["ConfiguredProject<br/>(tsconfig.json based)"]
PS --> IP["InferredProject<br/>(loose files)"]
PS --> EP["ExternalProject<br/>(build tool managed)"]
CP --> LS1["LanguageService"]
IP --> LS2["LanguageService"]
EP --> LS3["LanguageService"]
LS1 --> P1["Program"]
LS2 --> P2["Program"]
LS3 --> P3["Program"]
- ConfiguredProject: Created from a
tsconfig.jsonfile. When you open a.tsfile, the server searches upward for atsconfig.jsonand creates a project for it. - InferredProject: Created for files that don't belong to any
tsconfig.json. The server creates a project with default settings, including any loose files you have open. - ExternalProject: Created by external build tools that tell the server exactly which files and options to use.
Each project has its own LanguageService and Program. The ProjectService handles the lifecycle — creating projects when files are opened, updating them when files change, and disposing them when no longer needed.
The Server Entry Point
The src/tsserver/server.ts entry point initializes the Node.js system, creates a logger, configures cancellation, and then calls start(). One notable detail: it overrides console.log, console.warn, and console.error to redirect through the logger. This prevents language service plugins (which might use console.log) from corrupting the JSON protocol on stdout.
console.log = (...args) =>
logger.msg(args.length === 1 ? args[0] : args.join(", "), ts.server.Msg.Info);
console.warn = (...args) =>
logger.msg(args.length === 1 ? args[0] : args.join(", "), ts.server.Msg.Err);
console.error = (...args) =>
logger.msg(args.length === 1 ? args[0] : args.join(", "), ts.server.Msg.Err);
Tip: To debug tsserver, set the
TSS_LOGenvironment variable to a file path. The server will write detailed logs of every request, response, and internal event — invaluable for diagnosing editor integration issues.
The Full Picture
Having traced the complete TypeScript compiler across six articles, here's how all the pieces fit together:
flowchart TD
subgraph "User Interface"
Editor["VS Code / Editor"]
end
subgraph "Server Layer (src/server)"
tsserver["tsserver process"]
Session["Session"]
ProjectService["ProjectService"]
end
subgraph "Service Layer (src/services)"
LS["LanguageService"]
Completions["Completions"]
Diagnostics["Diagnostics"]
CodeFixes["CodeFixes (70+)"]
Refactors["Refactors (16)"]
end
subgraph "Compiler Core (src/compiler)"
Program["Program"]
Scanner["Scanner"]
Parser["Parser"]
Binder["Binder"]
Checker["Checker (54K lines)"]
Emitter["Emitter + Transformers"]
end
Editor <-->|"JSON protocol"| tsserver
tsserver --> Session
Session --> ProjectService
ProjectService --> LS
LS --> Completions
LS --> Diagnostics
LS --> CodeFixes
LS --> Refactors
LS --> Program
Program --> Scanner
Program --> Parser
Program --> Binder
Program --> Checker
Program --> Emitter
This architecture — thin entry points, a five-phase compilation pipeline, three core data structures (Node, Symbol, Type), and a clean layering from compiler core through language service to server — has served TypeScript for over a decade. As the team rewrites the compiler in Go for 10x performance, the fundamental architecture will remain similar. Understanding this JavaScript implementation gives you a deep foundation for reading the new one.
The TypeScript compiler is one of the most impressive pieces of open-source software in the JavaScript ecosystem. Whether you're contributing to TypeScript itself, building tools that consume its APIs, or just curious about how your favorite language works under the hood, I hope this series has given you the map you need to navigate with confidence.