Skip to content

Latest commit

 

History

History
382 lines (288 loc) · 23 KB

File metadata and controls

382 lines (288 loc) · 23 KB

typescript-native-bridge (TNB)

Published on npm as typescript-native-bridge.

A drop-in typescript replacement that type-checks on Go. Swap the typescript package for this fork and keep using tsc, vue-tsc, svelte-check, astro-check, glint, ESLint, and your editor exactly as before — the checker runs on tsgo (Microsoft's Go TypeScript compiler) in-process instead of JavaScript. No new CLI, no new LSP, no per-tool config, no code changes.


Why not just use TypeScript 7 (tsgo)?

typescript@7 is Microsoft's Go-native rewrite — but it doesn't drop into the tools you actually use:

  • vue-tsc / astro-check / svelte-check / glint are built on the classic typescript programmatic API (createProgram, Volar hooks, custom hosts). v7's programmatic surface is the new tsgo API — not a drop-in replacement for the classic one, so those tools can't just move to it.
  • ESLint (typescript-eslint) imports the classic typescript API and calls getTypeChecker() — same API mismatch.
  • Editors run tsserver + Language Service Plugins (@vue/typescript-plugin for .vue) — tsgo's LSP doesn't support that plugin model.

TNB keeps the classic package surface and puts the v7 engine (tsgo 7.x) behind it in-process — so one typescript override accelerates all of them at once.


Install

pnpm (monorepos)

# pnpm-workspace.yaml
overrides:
  typescript: npm:typescript-native-bridge@<version>
pnpm install
pnpm exec vue-tsc -b --noEmit    # or your project's typecheck script

If packages depend on typescript via catalog:, update the catalog entry too, or those packages still resolve stock TypeScript:

catalog:
  typescript: npm:typescript-native-bridge@<version>
overrides:
  typescript: npm:typescript-native-bridge@<version>

npm

// package.json
{
  "devDependencies": {
    "typescript": "npm:typescript-native-bridge@<version>"
  },
  "overrides": {
    "typescript": "$typescript"
  }
}

Use the alias and the $typescript override reference as shown — putting npm:typescript-native-bridge@… directly inside overrides is rejected or mis-resolved by some npm versions (issue #8). <version> is an exact version (e.g. 6.0.3-bridge.6.tsgo.7.0.2 — pin exactly; caret ranges don't match prerelease versions) or the latest dist-tag.

yarn

// package.json
{
  "resolutions": {
    "typescript": "npm:typescript-native-bridge@<version>"
  }
}

Local path (pinning a git checkout)

# pnpm-workspace.yaml
overrides:
  typescript: link:../typescript-native-bridge

The checkout must be built first (requires Go — npm run setup in the TNB repo).

After any override change: reinstall. The override applies repo-wide — vue-tsc, @typescript-eslint/parser, and every other transitive typescript consumer picks up the fork.


Confirm it's working

On the first type-check in a process, TNB prints one dimmed line to stderr:

▎ TNB ACTIVE — `typescript` is the tsgo-backed fork

No banner = stock typescript is still loaded. See Troubleshooting.

node -e "console.log(require.resolve('typescript'))"
# should point at typescript-native-bridge, not node_modules/typescript@6.x

Verified compatible tools

Verified means: the tool runs on the fork and its behavior matches tsgo's on the stated workload (no crash, no silent under-reporting, no false positives beyond the differences from tsgo).

Tool Status Verified on
tsc ✅ compiler test corpus
vue-tsc ✅ elk.zone monorepo (~2,000 files): emitted-error parity with stock, ~3× faster
astro-check ✅ fixture project: output identical to stock
svelte-check ✅ fixture project: output identical to stock (incl. svelteHTML ambient shims)
glint ✅ fixture project: same error set as stock (transformed .gts virtual files)
mdx-tsc ✅ fixture project: diagnostic output identical to stock (Volar runTsc, errors mapped to MDX source spans)
ESLint + typescript-eslint (type-aware rules) ✅ 1,000-file type-aware corpus: lint output byte-identical to stock
tsserver + @vue/typescript-plugin ✅ volar language-tools test suite: 205/209 pass (4 skipped)
tsslint ✅ runs as the volar repo's own linter

Continuous verification: a CI gate (every push/PR and nightly) replays the language-service probe corpus (quickinfo / definition / references / diagnostics, ~19k units) against the same stock build — no new divergences allowed. If your tool isn't listed, try it and file an issue; the fork covers any tool that drives the standard typescript Compiler API.

Framework specifics

  • .vue, .svelte, .astro, .mdx, .gts etc. via the standard extraFileExtensions contract — no hard-coded per-framework special case.
  • Host-injected virtual content (Volar virtual TS, glint's transformed modules, svelte's ambient shims) reaches the Go checker.
  • allowArbitraryExtensions is inferred true when host extra extensions are present and tsconfig leaves it unset; explicit false opts out.
  • Not supported: custom resolveModuleNames / resolveModuleNameLiterals that remap an import to a different physical file (the bridge is synchronous JS→Go; tsgo cannot call back into JS resolvers).

Performance

Measured on this repo's benchmarks (Apple Silicon; your repo will differ — measure):

Workload Stock typescript TNB
vue-tsc -b full check (elk.zone, ~2,000 files) 9.7s 3.2s ~3×
type-aware ESLint, single-run (1,000 plain-TS files, one program) 2.3s 2.4s +1.5%
same, 3,000 files 6.9s 6.8s ~parity
JS heap peak (1,000-file ESLint fixture) 769MB 631MB −18%
peak RSS, whole-process (vue-tsc -b; TNB's includes the in-process Go checker) 1.8GB 3.3GB ~1.9× — structural

The rule is simple: wherever the time is in the checker, TNB is faster. The question for any workload is how much of its time that phase is — and how much of it pays the JS↔Go boundary instead.

vue-tsc -b (checker-dominated — the big win): the whole-program semantic pass drops from ~5.5s (JS checker) to ~1.5s (Go checker), and most of stock's ~2.6s full-program parse+bind never happens — TNB's thin program materializes files lazily, on demand. The rest is Volar codegen and JS-side work both sides pay. The Go checker's in-process program state is also why TNB's whole-process RSS runs higher than stock's on this workload — JS heap stays lower; RSS is the honest whole-process figure.

Editor / LS path (Volar + tsserver): the V8-arena transport (fixed-layout records written straight into V8 memory, DataView reads, interned strings) keeps per-keystroke work near the transport floor. Measured on a 5,537-request roam over the volar corpus (one long-lived session): ~1.0 bridge RPC per request; p50 quickinfo 0.16ms, completionInfo 0.23ms, references 1.5ms (p95 7.4 / 9.0 / 48ms — means are tail-dominated). Per-request byte figures in older revisions of this section described the bridge-internal JS↔Go channel, not the editor-facing tsserver wire.

The carve-out — single-run type-aware ESLint is the workload with the least to gain. Its time is in parsing, AST conversion and rule execution (work both sides pay), and its type-aware queries arrive as tens of thousands of tiny calls (~44K checker RPCs per 1,000 files after the bridge's per-generation memoizing) that measure the JS↔Go boundary, not the engine. TNB lands within ~2% of stock at both sizes — parity, not a win; peak JS heap stays at or below stock. The memory wins live on the long-session editor path (see release notes).


Editor / tsserver (VS Code, Cursor)

CLI typecheck picks up TNB automatically. The editor does not — VS Code ships its own TypeScript and only uses yours when you opt in.

1. Workspace settings (commit .vscode/settings.json for the team):

{
  "js/ts.tsdk.path": "node_modules/typescript/lib",
  "js/ts.tsdk.promptToUseWorkspaceVersion": true
}

Use a path relative to the workspace folder that contains node_modules.

2. Switch to the workspace version (once per machine):

Command Palette → TypeScript: Select TypeScript Version → Use Workspace Version.

3. Verify: the version picker shows a path under node_modules/typescript/lib; the Output → TypeScript channel may show TNB ACTIVE on first project load. Vue/Nuxt users: keep @vue/typescript-plugin in tsconfig compilerOptions.plugins as today — it runs as a tsserver LS Plugin on this fork.

CLI Editor
Override needed Yes Yes (same node_modules/typescript)
Extra config No js/ts.tsdk.path + Use Workspace Version

Behavior and differences from tsgo

The checker's behavior is tsgo 7.0.2's (Microsoft's Go TypeScript), not stock TypeScript 6.0.3's — migrating from stock means inheriting tsgo's diagnostics, bundled libs, and display output as-is.

TNB's own changes to tsgo behavior — the complete list, enforced by CI:

Change Why Upstream Removal
getTypeFromTypeNodeWorker resolves type-position entity names (identifier / qualified name / property access) Silent wrong result on the headline path: hover on P in [P, (typeof OBJ)[P][number]] read any instead of the type parameter (issue #30) repro branch repro/type-position-entity-reads-any (issue pending) When the upstream fix lands
tsgo-symbolflags-typealias-display: typeParametersToTypeParameterDeclarations checks `Class Interface TypeAlias(stock'sSymbolFlags.TypeAlias) instead of Class Interface
tsgo-immaliased-nil — bridge-only guard (no tsgo behavior change): getImmediateAliasedSymbol returns nil instead of panicking for declaration-less synthetic aliases; only the bridge's exposed API can hit the branch — tsgo's own callers already nil-check the result A Go panic on the bridge's NAPI boundary is process-fatal where stock's failure is a catchable JS throw — —
tsgo-instsymbol-active-guard — bridge-only guard (no tsgo behavior change): getTypeOfInstantiatedSymbol/getWriteTypeOfInstantiatedSymbol dispatch by flags when cross-checker reuse yields a nil target or a cyclic instantiation chain; the guards never fire on stock-shaped data Per-checker valueSymbolLinks go empty/cyclic only when the bridge reuses a SymbolHandle across checkers — —
tsgo-ctx-initializer-nil-guard — bridge-only guard (no tsgo behavior change): getContextualType returns nil for a parentless node instead of nil-dereferencing; parsed trees always parent non-root nodes, so only the bridge's GetContextualType RPC can supply one The bridge runs stock-derived callers that can pass synthetic/parentless nodes — —
tsgo-extra-file-extensions — extraFileExtensions resolution surface (stock parity): host-registered extensions (e.g. .vue) are threaded through program loading, triple-slash/type-reference/module resolution, string-completion extension search, parse script-kind inference, and the checked-file completion path; allowArbitraryExtensions is inferred when unset; typeRoot resolutions carry stock's PackageId; an extra-extension source emits its declaration as stock's Button.vue.d.ts, not tsgo's import-lookup form Button.vue.d.vue.ts Volar/svelte-check-style language plugins register extraFileExtensions; stock resolves the registered host file and completes inside it, tsgo previously ignored the option (nil everywhere) and misclassified/resolved these files, and vue-tsc declaration builds wrote unimportable .d.vue.ts names (issue #63) — pristine tsgo rejects a non-script root (TS6054) and never emits one, so that name is reachable only through this surface, not an upstream emit bug upstream issue pending When upstream implements the extraFileExtensions surface
tsgo-checker-pool-strides — whole-program diagnostics assign files to checkers by index stride (i % groups) with checker releases deferred past RunAndWait, instead of goroutine-scheduled queue pickup Checker-local lazy state (merged-global heritage resolution, report dedup) made per-file blame appear/disappear across identical runs (issue #42), and small programs riding a single dedicated checker deterministically dropped merged-global TS2430 blame sites that both stock tsc and the strided pool report (issues #42/#51) upstream issue pending When tsgo's whole-program pass is run-stable by construction
tsgo-autoimport-kind-alias — auto-import createExport presents the export-map entry's ScriptElementKind as alias (the entry's own kind) instead of the target symbol's kind Auto-import entries showed the target's kind (e.g. class) where stock shows alias; kind drives the completion icon editors render upstream issue pending When upstream fixes the export-map entry kind
tsgo-autoimport-inprogram-gate — auto-import search only surfaces a package's exports-subpath modules when the package is a package.json-listed dependency or the module is already in the program Without the gate, a dependency's exports-subpath modules (never in the program, never provider-covered) leaked in as auto-import candidates stock never offers upstream issue pending When upstream implements stock's coverage split
tsgo-typeat-nil-parent-guard — bridge-only guard (no tsgo behavior change): GetTypeOfSymbolAtLocation nil-checks location.Parent before JSX/set-accessor classification; parsed trees always parent non-root nodes, so only the bridge's GetTypeOfSymbolAtLocation RPC can supply a parentless location A Go nil-dereference on the bridge's NAPI boundary is process-fatal where stock's failure is a catchable JS throw — —
tsgo-osvfs-executable-fallback — isFileSystemCaseSensitive silently falls back (case-insensitive on darwin/windows, case-sensitive elsewhere) when os.Executable fails, instead of panicking Forked Node workers (vitest pool=forks) may not resolve argv0 on macOS, and the panic is process-fatal on the bridge where stock's sys.ts is equally best-effort about __filename upstream issue pending When upstream lands a non-panicking fallback
tsgo-autoimport-markbuckets-dirty — markBucketsDirty keeps a bucket in the markFilesDirty worklist until it actually goes multiple-files-dirty (pristine ejected it after the first mark, before multipleFilesDirty was set, so a second same-batch file's dirty mark was eaten) When two project files change in one update batch, the second file's invalidation was lost on ~50% of runs (map iteration order), leaving its exports stale in auto-import completions (volar #5847); pinned by TestMarkBucketsDirtyTwoFileBatchBothLand volar #5847; upstream issue pending When upstream fixes the worklist ejection
tsgo-declaredtype-nil-symbol — bridge-only guard (no tsgo behavior change): tryGetDeclaredTypeOfSymbol returns nil for a nil symbol instead of nil-dereferencing; a type-only ImportClause is an IsTypeDeclaration node the binder never gives a symbol, and only the bridge's GetTypeAtLocation RPC supplies such nodes — tsgo's own LSP routes hover types through shouldGetType's kind allowlist, which excludes ImportClause (issue #71) A Go panic on the bridge's NAPI boundary is process-fatal where stock's failure is a catchable JS throw — —
tsgo-tuple-base-type-target — bridge-only guard (no tsgo behavior change): getBaseTypes resolves a tuple through its target and getTupleBaseType reads the readonly modifier off the type the caller asked about, so the arity-0 array-literal clone (Tuple in its own objectFlags, *TypeReference data — no tuple data, no readonly field, exactly what stock reads as undefined) answers the mutable never[] while a declared readonly [] keeps readonly never[]; the nil-data arm returns no base types instead of dereferencing nil A Go panic on the bridge's NAPI boundary is process-fatal where stock answers, and the exposed GetBaseTypes RPC accepts any type — tsgo's own callers only pass types they already know to be class or interface (issue #73) microsoft/TypeScript#63869 — fixed by the merged #64080, which guards tuple data at the proto SERIALIZER only and leaves getBaseTypes untouched; the getBaseTypes hole is unfixed upstream at main. The unmerged typescript-go#4803 / #4870 proposed the same target read When tools/check-pristine-attribution.mjs reports UPSTREAM-FIXED for this key — i.e. the pristine clone at the pin no longer panics on the repro test
tsgo-relater-recursion-identity — backport of upstream's relater change: recursion identities see through indexed accesses and homomorphic mapped types, isDeeplyNestedType checks each intersection constituent, so deep structural chains are cut as deeply nested before the stack limit; the separate stack-depth overflow kind (TS2321) is gone, a remaining overflow is the complexity one Silent wrong result on the headline path: one deep comparison (e.g. a PartialDeep helper over a large class) reported a false TS2321 and left its failed sub-relations cached, so later unrelated assignments failed too — stock 6.0.3 and tsgo main report nothing (issue #77) typescript-go#4913 (merged 2026-08-18, fixes typescript-go#4807); not on the 7.0 release branch At the base bump past upstream 12548e2a1 — tools/check-pristine-attribution.mjs reports UPSTREAM-FIXED once the pin carries it

Anything that looks like a difference from stock 6.0.3 but isn't listed here is tsgo's own behavior, not TNB's. Found an actual TNB-only divergence? File an issue with a minimal repro.


Platform support

The bridge binary ships as per-platform optional dependencies; npm install pulls only the one matching your machine (the main package is pure JS):

Platform Sub-package
macOS Apple Silicon @typescript-native-bridge/darwin-arm64
macOS Intel @typescript-native-bridge/darwin-x64
Linux x64 @typescript-native-bridge/linux-x64
Linux arm64 @typescript-native-bridge/linux-arm64
Linux arm (32-bit) @typescript-native-bridge/linux-arm
Windows x64 @typescript-native-bridge/win32-x64
Windows arm64 @typescript-native-bridge/win32-arm64

Linux packages target glibc 2.35 and are rejected by the release gate if they acquire a newer symbol requirement. (They targeted 2.31 until bridge.17, built on debian:bullseye-slim; Debian retired those packages from the bullseye-security pool in 2026-09 so the image can no longer install its own build dependencies, and the successor floor is Ubuntu 22.04 / jammy.) Alpine/musl is not supported: Go's -buildmode=c-shared runtime crashes at load on musl libc — even a trivial hello-world c-shared library segfaults, on Go 1.22 through 1.26 alike (golang/go#13492, a 10-year-open upstream issue with an active fix in golang/go#75048; tsgo's own CLI works on Alpine only because it ships CGO-free static binaries, and a NAPI bridge cannot be CGO-free). Workaround: run the typecheck/lint step in a glibc-based image (node:24 or node:24-bookworm-slim) and deploy into the Alpine stage of your multi-stage build; apk add gcompat does not help.

On an unsupported platform the loader fails with a clear "unsupported platform or missing optional dependency" error — build from source there (clone with submodules, then npm run setup; requires Go + a C toolchain).


Troubleshooting

No banner appears

Check Action
Override at workspace root Monorepo: pnpm-workspace.yaml, not a leaf package
pnpm 11 Move package.json → pnpm.overrides to pnpm-workspace.yaml → overrides: (pnpm 11 no longer reads the pnpm field — silently ignored)
catalog: pin Update catalog and overrides
Stale install pnpm install again; clear CI cache if needed
Wrong resolution node -e "console.log(require.resolve('typescript'))"

CLI works, editor doesn't (or vice versa)

  • CLI OK, editor not: add the tsdk settings and run TypeScript: Select TypeScript Version → Use Workspace Version. The override alone is not enough for the editor.
  • Editor OK, CLI not: check require.resolve('typescript') — should point at TNB. Reinstall after changing overrides.

Type errors differ from stock

Expected — the checker's behavior is tsgo 7.0.2's, not stock 6.0.3's, so output can differ from stock (see Behavior and differences from tsgo). What is a bug: output that differs from tsgo itself — file an issue with a minimal repro.

Missing native bridge

"bridge shared library not found" → see Platform support (build from source, or use a link: install built with npm run setup).

"bridge.node was built for typescript-native-bridge X, this bundle is Y" → the loaded native bridge comes from another release (typically a stale @typescript-native-bridge/<platform>-<arch> left in a package-manager store). Reinstall so the platform package matches the main package; from a source checkout, run npm run build:bridge.

Debug a slow run

TSGO_PROFILE=1 prints a [tsgo-profile] RPC/timing summary to stderr on process exit.


Uninstall / rollback

Remove the typescript override, reinstall, confirm:

pnpm install
node -e "console.log(require.resolve('typescript'))"   # stock typescript@6.x again

No source changes required.


FAQ

Do I need to change my code? No.

Do I configure vue-tsc / ESLint / my editor plugin separately? No. They import typescript; one override covers them.

Is this the same as TypeScript 7 / tsgo? Same engine, different package. TNB pins tsgo 7.x as its checker (the version string ends in tsgo.7.0.2), but keeps the classic typescript API and tsserver in front of it. typescript@7 gives you the new tsgo API and its own LSP instead — see Why not just use TypeScript 7?

How much faster is it? See Performance — biggest on vue-tsc-style full-program workloads. Measure on your own repo.


License

Apache-2.0. See LICENSE and NOTICE.

This package is a derivative work of Microsoft TypeScript and microsoft/typescript-go.