Read OSS

The Language Service and tsserver: Powering IDE Experiences

Advanced

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 LanguageServiceHost and call createLanguageService(). 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:

  1. What kind of completion context? — Member access (foo.), string literal (import path), JSX attribute, type position, statement position, etc.
  2. What symbols are in scope? — Using the checker's getSymbolsInScope() for identifier completions, or getPropertiesOfType() for member access
  3. 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:

  1. Syntactic diagnostics — fast, from the parser, no type checking needed
  2. Semantic diagnostics — from the checker, requires full type checking
  3. 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 constant
  • moveToFile.ts / moveToNewFile.ts — Move declarations between files
  • convertParamsToDestructuredObject.ts — Convert positional parameters to named object
  • convertImport.ts — Switch between named imports and namespace imports
  • inlineVariable.ts — Inline a variable at all reference sites
  • convertToOptionalChainExpression.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:

  1. Extracts parameters from the request
  2. Converts line/column positions to internal offsets
  3. Calls the appropriate LanguageService method(s)
  4. 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.json file. When you open a .ts file, the server searches upward for a tsconfig.json and 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_LOG environment 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.