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
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.locatereturnsstring option.XANTHAM_TSGO_EXE, if set to an existing file, wins over the search.Treat
Noneas "the caller has not runnpm install" and say so — it is the single most common setup failure.
Two clients
TscChannel
Use Api.* for one request at a time. Give each concurrent caller its own channel.
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.ValueNoneis an answer, not a failure: no symbol at that position, no such file in this project.- Server-side failures raise
TsGoError(method, message). channel.Diagnosticsreturns 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
- ApiApi method functions taking the channel and the method's parameter record.
- ProtoMessage Content records for different api methods
- Sync Channel ExtensionsPer API method extensions for the TscChannel
- Async Mailbox ExtensionsPer 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:
| Reply | Meaning |
|---|---|
Content text | this is the file, Content "" included |
Missing | the 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.
0x2areads back"42". For the spelling, sliceAst.sourceTextbetweennode.Posandnode.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, not11u. - A
TscChannelserialises requests. Sharing one across threads corrupts the stream; use aTscMailbox, or a channel per thread. initializefirst, before any other method, on both clients.
Where to go next
- Navigating the AST — sessions in depth,
Node<'Tag>, views, accessors, node handles. - Contributing to Xantham — build, test, and maintain the compiler client.