Logo Xantham

Using Xantham.TypeScript.Wire

How to consume the package: install it, get a compiler talking, call the API, read what comes back. For navigating the AST once you have it, see wire-navigation.md.

Install

dotnet add package Xantham.TypeScript.Wire
npm install typescript@7.1.0-dev.20260830.1

The compiler is not bundled. Wire runs the Go tsc binary shipped in the typescript npm package as tsc --api. The protocol is unversioned, so the npm pin must match the version the package was generated against — mismatches surface as decode failures, not as a version error.

Targets net10.0, net8.0, netstandard2.1. Only dependency: System.Text.Json.

Locate the executable

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

Use

Concurrency

TscChannel

synchronous Api.*

one request at a time, not thread-safe

TscMailbox

Async AsyncApi.*, batches overlapping calls

safe from many callers; 2.1–2.3× under pressure

Both own a child process and both are IDisposableuse, 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

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; wire-navigation.md 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 wire-navigation.md.

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

Where to go next

val exe: 'a
val channel: 'a
val ignore: value: 'T -> unit
val snapshot: 'a
union case ValueOption.ValueSome: 'T -> ValueOption<'T>
val project: 'a
val program: 'a
val session: 'a
val symbol: 'a
val diagnostics: 'a
val updated: 'a
union case ValueOption.ValueNone: ValueOption<'T>
val diagnostics: 'a (requires 'a :> 'b seq)
val d: 'a
val failwith: message: string -> 'T
val ast: 'a
val printfn: format: Printf.TextWriterFormat<'T> -> 'T
val handle: string
val ty: 'a
val moduleSymbol: 'a
Multiple items
module ValueOption from Microsoft.FSharp.Core

--------------------
type ValueOption<'T> = | ValueNone | ValueSome of 'T static member Some: value: 'T -> 'T voption static member op_Implicit: value: 'T -> 'T voption member IsNone: bool member IsSome: bool member Value: 'T static member None: 'T voption
val defaultValue: value: 'T -> voption: 'T voption -> 'T
val mailbox: 'a
Multiple items
type Async = static member AsBeginEnd: computation: ('Arg -> Async<'T>) -> ('Arg * AsyncCallback * objnull -> IAsyncResult) * (IAsyncResult -> 'T) * (IAsyncResult -> unit) static member AwaitEvent: event: IEvent<'Del,'T> * ?cancelAction: (unit -> unit) -> Async<'T> (requires delegate and 'Del :> Delegate and 'Del: not null) static member AwaitIAsyncResult: iar: IAsyncResult * ?millisecondsTimeout: int -> Async<bool> static member AwaitTask: task: Task<'T> -> Async<'T> + 1 overload static member AwaitWaitHandle: waitHandle: WaitHandle * ?millisecondsTimeout: int -> Async<bool> static member CancelDefaultToken: unit -> unit static member Catch: computation: Async<'T> -> Async<Choice<'T,exn>> static member Choice: computations: Async<'T option> seq -> Async<'T option> static member FromBeginEnd: beginAction: (AsyncCallback * objnull -> IAsyncResult) * endAction: (IAsyncResult -> 'T) * ?cancelAction: (unit -> unit) -> Async<'T> + 3 overloads static member FromContinuations: callback: (('T -> unit) * (exn -> unit) * (OperationCanceledException -> unit) -> unit) -> Async<'T> ...

--------------------
type Async<'T>
static member Async.RunSynchronously: computation: Async<'T> * ?timeout: int * ?cancellationToken: System.Threading.CancellationToken -> 'T
val results: Async<'a array>
module Array from Microsoft.FSharp.Collections
val map: mapping: ('T -> 'U) -> array: 'T array -> 'U array
val f: 'a
static member Async.Parallel: computations: Async<'T> seq -> Async<'T array>
static member Async.Parallel: computations: Async<'T> seq * ?maxDegreeOfParallelism: int -> Async<'T array>
val fs: 'a
val channel: 'a (requires 'a :> System.IDisposable | null)
val edited: 'a
val output: 'a

Type something to start searching.