Skip to content

TypeScript.Wire

Read syntax trees, query types, and inspect diagnostics from .NET using TypeScript 7’s own compiler API.

Xantham.TypeScript.Wire is the compiler client used by the generator and is available as an independent package for your own tools.

Install

Terminal
dotnet add package Xantham.TypeScript.Wire
npm install --save-exact typescript@7.1.0-dev.20260902.1

Frameworks

Supports net10.0, net8.0, netstandard2.1. Only dependency is System.Text.Json.

Locate TSC

  • my-project
    • node_modules
      • **
  • Program.fs
open Xantham.TypeScript.Wire

// Walks up from the directory looking for node_modules/@typescript/typescript-<rid>/lib/tsc.
let exe = (Tsc.locate "./my-project").Value
  • Tsc.locate returns string option.

  • XANTHAM_TSGO_EXE, if set to an existing file, wins over the search.

  • Treat None as "the caller has not run npm install" and say so — it is the single most common setup failure.

Two clients

Synchronous

TscChannel

Use Api.* for one request at a time. Give each concurrent caller its own channel.

Asynchronous

TscMailbox

Use AsyncApi.* for concurrent callers. Overlapping requests are batched.

Both own a child process and both are IDisposable — use, don't let. Disposal closes stdin, which is the documented shutdown; a leaked instance leaks a tsc process.

The lifecycle

initialize once, then get a snapshot and a project. Every subsequent request is addressed by that pair.

From a tsconfig

open Xantham.TypeScript.Wire
open Xantham.TypeScript.Wire.Proto

use channel = new TscChannel(exe, "./my-project")
Api.initialize channel |> ignore

let snapshot =
    Api.updateSnapshot channel
        { UpdateSnapshotParams.Default with
            OpenProjects = ValueSome [| DocumentIdentifier.FileName "./my-project/tsconfig.json" |] }

let project = snapshot.Projects[0].Id

From root files, no tsconfig

let program =
    Api.createProgram channel
        { CreateProgramParams.Default with
            RootFiles = ValueSome [| DocumentIdentifier.FileName "./my-project/index.d.ts" |] }

let snapshot, project = program.Snapshot, program.Project.Value.Id

Opens are ref-counted and persist across snapshots. Api.release channel { Snapshot = id } drops one when you are done with it; updateSnapshot with CloseProjects/CloseFiles unwinds the opens themselves.

Either way you end up holding the pair. A session binds it once so no later call has to repeat it.

Three ways to call the same method

All 142 methods exist in each form. Pick one per codebase and stay with it.

// 1. Module function, explicit record. Most greppable; what the docs use.
Api.getSourceFile channel { Snapshot = snapshot.Snapshot; Project = project; File = file }

// 2. Extension member, record. No channel threading.
channel.getSourceFile { Snapshot = snapshot.Snapshot; Project = project; File = file }

// 3. Extension member, fields spread as named arguments. Optional fields are optional arguments.
channel.getSourceFile(snapshot = snapshot.Snapshot, project = project, file = file)

AsyncApi.* mirrors Api.* over TscMailbox, same names and types wrapped in Async, with the same three forms. The one exception is batchRequests, which has no async counterpart — the mailbox is the batcher.

There is a fourth form, the session, which drops the two arguments that never vary; it gets its own section below.

Parameter records

Every field the schema marks optional is voption and every all-optional record carries a Default. Copy-update it rather than writing the fields out:

{ GetDiagnosticsParams.Default with Files = ValueSome [| file "main.ts" |] }

DocumentIdentifier is FileName of string or Uri of string; paths may be relative to the channel's cwd.

Result contract

  • 'T — the schema declares a result, and a missing one raises.
  • 'T voption — the schema permits null. ValueNone is an answer, not a failure: no symbol at that position, no such file in this project.
  • Server-side failures raise TsGoError(method, message).
  • channel.Diagnostics returns everything the process has written to stderr — panics and log noise. Include it when reporting a failure; it is usually the only explanation.
  • API Reference
  • Api
    Api method functions taking the channel and the method's parameter record.
  • Proto
    Message Content records for different api methods
  • Sync Channel Extensions
    Per API method extensions for the TscChannel
  • Async Mailbox Extensions
    Per API method extensions for the TscMailbox

A session binds the snapshot and the project

126 of the 142 methods lead with the same two arguments — the snapshot and the project — because that pair is what the compiler resolves everything else against. Session<'T> holds the pair and re-exposes those methods with the two arguments removed, in the named-argument form:

let session = channel.Session program    // the createProgram response already carries the pair

let symbol = session.getSymbolAtPosition(file "main.ts", 42)
let diagnostics = session.getSemanticDiagnostics(files = [| file "main.ts" |])

channel.Session(...) and mailbox.Session(...) each accept the createProgram response, an updateSnapshot response — naming the project when the snapshot holds more than one — or a raw snapshot id and project id. Session<TscChannel> answers synchronously and Session<TscMailbox> in Async, under the same member names, so a call site changes transport by changing how the session was built.

The pair is data, not identity. WithSnapshot and WithProject rebind one half, and ForSymbol symbol retargets to the project a symbol was first observed in:

let updated = session.Sessionless.updateSnapshot(openFiles = [| file "main.ts" |])
let session = session.WithSnapshot updated.Snapshot

The 16 methods that take neither argument — initialize, updateSnapshot, createProgram, the transpile* and config-parsing family — precede any snapshot, so they hang off session.Sessionless rather than being absent. Handles are valid for exactly the pair a session holds; Navigating the AST covers that scope and where the layer is generated from.

Common patterns

Diagnostics for a file

match Api.getSemanticDiagnostics channel
          { Snapshot = snapshot.Snapshot; Project = project; Files = ValueSome [| file "main.ts" |] } with
| ValueNone -> []
| ValueSome diagnostics -> [ for d in diagnostics -> d.Code, d.Text ]

Assert on Code (2322), not on Text — the prose changes upstream, the code does not. getSyntacticDiagnostics takes the same record.

Read a file's AST

match Api.getSourceFile channel { Snapshot = snapshot.Snapshot; Project = project; File = file "main.ts" } with
| ValueNone -> failwith "not in this project"
| ValueSome ast ->
    printfn $"%d{ast.NodeCount} nodes in %s{Ast.fileName ast}"
    Node.root ast   // Node<SourceFile> - continue in wire-navigation.md

One request brings the whole file: every node is readable from the blob with no further round trips. Api.getSourceFileNames lists what the project contains.

Ask the checker about a node

Checker requests take a Location — a node handle, the string index.kind.path:

let handle = $"{Node.index node}.{uint32 node.Kind}.{Ast.path (Node.file node)}"
let ty = Api.getTypeAtLocation channel { Snapshot = snapshot.Snapshot; Project = project; Location = handle }

Handles are valid only within the snapshot and project that produced them. Going the other way — a handle from symbol.Declarations back to a node — is in Navigating the AST.

Enumerate a module's exports

match Api.getSymbolOfSourceFile channel { Snapshot = snapshot.Snapshot; Project = project; File = file "index.d.ts" } with
| ValueNone -> [||]
| ValueSome moduleSymbol ->
    Api.getExportsOfModule channel { Snapshot = snapshot.Snapshot; Project = project; Symbol = moduleSymbol.Id }
    |> ValueOption.defaultValue [||]

SymbolResponse.Flags and .CheckFlags are typed enums — read them by name (symbol.Flags.HasFlag SymbolFlags.Property), never by bit.

Concurrent calls, batched

use mailbox = new TscMailbox(exe, "./my-project")
Async.RunSynchronously(AsyncApi.initialize mailbox) |> ignore

let! results =
    files
    |> Array.map (fun f -> mailbox.getSourceFile(snapshot = snapshot, project = project, file = f))
    |> Async.Parallel

Whatever queues while a batch is in flight goes out in the next batchRequests round trip. A lone caller never batches and pays nothing for the mailbox.

Sources that exist only in memory

let fs =
    { VirtualFileSystem.Default with
        ReadFile =
            ValueSome(fun path -> if path = "/virtual.ts" then Content "export const a = 1" else FallBack)
        FileExists = ValueSome(fun path -> if path = "/virtual.ts" then ValueSome true else ValueNone) }

use channel = new TscChannel(exe, cwd, VirtualFileSystem.callbacks fs)

Only the members you set are registered, and only those are ever asked about. The three-way FileRead matters:

ReplyMeaning
Content textthis is the file, Content "" included
Missingthe file does not exist; resolution stops here
FallBack (or ValueNone)not answered; the server reads the real filesystem

Missing where you meant FallBack silently changes module resolution.

For a one-off edit to a file that does exist, Api.updateTemporarySnapshot is cheaper than a virtual filesystem:

let edited =
    Api.updateTemporarySnapshot channel
        { Snapshot = snapshot.Snapshot; File = file "main.ts"; NewText = "export const a = 1" }

Transpile without a project

let output =
    Api.transpileModule channel
        { Input = "const a: number = 1"
          Options = { TranspileOptions.Default with ReportDiagnostics = ValueSome true } }
output.OutputText

transpileModule/transpileDeclaration need no snapshot or project — only initialize.

Gotchas

  • Positions are UTF-16 code units. They index straight into Ast.sourceText ast; no conversion, and no byte offsets.
  • Node text is cooked, not the source spelling. 0x2a reads back "42". For the spelling, slice Ast.sourceText between node.Pos and node.End.
  • Absent and empty are indistinguishable in the AST blob: an empty collection is written the same as a missing one.
  • Never write a kind or flag ordinal. They are positional upstream and move. SyntaxKind.StringLiteral, not 11u.
  • A TscChannel serialises requests. Sharing one across threads corrupts the stream; use a TscMailbox, or a channel per thread.
  • initialize first, before any other method, on both clients.

Where to go next

Edit this page