DEV Community

Cover image for Bun's TypeScript Support in 2026: What Works, What Still Needs tsc
Gabriel Anhaia
Gabriel Anhaia

Posted on

Bun's TypeScript Support in 2026: What Works, What Still Needs tsc


You've seen it. A project switches its dev loop from `tsc-watch

  • node --loader to a single bun run src/index.ts. The cold start drops from three seconds to two hundred milliseconds. Hot edits feel weightless. The team ships the change, deletes the watcher, and moves on. A week later a PR lands that introduces a real type error. A Promise gets returned where the caller expects User. CI is green. The runtime is happy. The bug ships. Production gets a .then is not a function (or worse, a silent undefined`) ten minutes after deploy.

Teams misread what Bun actually does with their TypeScript.
Bun runs .ts and .tsx files without a build step. That is
true. From there, a tired team draws the wrong conclusion:
that Bun has replaced their type checker. It has not. Bun
transpiles TypeScript at runtime. It doesn't check types.
That distinction matters.

The rule is simple. The line between what Bun handles
natively and what still needs tsc is sharp and documented,
stable enough in 2026 to bet a CI matrix on.

What Bun does natively

Bun ships TypeScript as a first-class runtime input. There is
no tsconfig.json required, no ts-node shim, no --loader
flag. You point bun at a .ts file and it runs.

bun run src/server.ts
bun test
bun build src/index.ts --outfile dist/index.js
Enter fullscreen mode Exit fullscreen mode

Under the hood, Bun's transpiler
parses the file, strips type annotations, lowers any modern
syntax the runtime needs lowered, and hands JavaScript to its
JavaScriptCore-based engine. The transpile is in Zig, the
output is cached, and the resulting startup time is what
people notice first.

Bun handles more TypeScript than people expect:

  • Type annotation stripping — every : Foo, every as cast, every satisfies. Pure erasure, no type semantics.
  • Decorators — both the stage-3 standard decorators and the legacy experimentalDecorators shape, picked up from tsconfig.json when present.
  • JSX / TSX — out of the box. Bun reads jsx, jsxImportSource, and jsxFactory from your tsconfig.
  • paths mapping@/lib/* style aliases work without a separate tsconfig-paths runtime hook. Bun's resolver reads the config and rewrites imports.
  • using and await using — the explicit-resource-management syntax from TypeScript 5.2+ runs natively. Bun lowers it when needed for older targets.
  • ESM, top-level await, JSON imports, import.meta — the modern module surface works without flags.
  • Source maps — emitted automatically so stack traces point at your .ts lines, not the transpiled output.

The practical effect is that the inner dev loop no longer has
a build step. Edit, save, re-run. For library code, scripts,
servers, CLI tools — anything where the loop was previously
"tsc-watch in one terminal, node in another" — Bun collapses
both into one process.

That is where the speed comes from. The wall-clock saving on
a small project is dramatic. The saving on a monorepo with
project references is even bigger, because Bun does not care
about the project graph; it transpiles the file in front of
it.

What Bun deliberately skips

Here's the catch. Bun's documentation is upfront about it,
but the line in the docs is one sentence inside a longer
page, and people skim past. Bun's docs say plainly that it
does not type-check your code.

Concretely, Bun's transpiler treats TypeScript the same way
esbuild
does — as JavaScript with extra syntax that has to come out
before the engine sees it. No type graph gets built. Generics
aren't resolved. Nothing checks that the value returned from
getUser() matches the Promise<User> you typed. The
annotation gets stripped and the code runs.

The shortlist of what Bun will not do for you:

  • No type checking. A string assigned to a number runs fine until the runtime trips over it.
  • No .d.ts emission. Bun has no declaration emitter. If you publish a library, you still need tsc --emitDeclarationOnly (or oxc / swc / a parallel declaration tool) to produce the declaration files your consumers' editors read.
  • No const enum inlining. Bun erases the values. If your code depends on the inlined-literal behavior, you get surprises. The fix is to not use const enum in published libraries — which is the right fix anyway in 2026.
  • No project references. tsc -b walks references arrays and orchestrates incremental builds across packages. Bun treats every file as standalone. For a monorepo's type-correctness story, you still want tsc -b --noEmit.
  • No tsc-shaped diagnostics. Bun reports parse errors and runtime errors. It does not report "Type 'X' is not assignable to type 'Y'."

The ESM/CJS interop surface is the other place to be careful.
Bun is more forgiving than Node about importing CommonJS from
ESM — require() works in .ts files even when tsconfig says
"module": "ESNext", and CJS default imports unwrap in ways
Node refuses to. Fine for an app that only runs on Bun. A
hazard for a library, because the same code can break when a
consumer pulls it into Node. Library authors should target
the strict ESM/CJS rules of their publish target. What Bun
lets you get away with isn't the contract your consumers run.

Bun handles transpile, JSX, decorators, paths, using; tsc still owns type-check, .d.ts emit, project refs

Library authoring: where tsc still owns the build

For a library that ships to Node, Bun, Deno, and bundlers, the
shape of the build pipeline in 2026 is two-stage:

  1. Bundle / transpile — Bun, esbuild, tsup, or rolldown. Whichever you pick, this stage produces the .js your consumers run.
  2. Declaration emittsc --emitDeclarationOnly (or oxc / swc with --isolatedDeclarations if you've turned that flag on). This stage produces the .d.ts your consumers' editors read.

The mistake is to think Bun replaces stage 2. It does not.
Bun has no declaration emitter, and there is no plan to add
one. Declaration emit needs the type checker, and the type
checker is the expensive part Bun is deliberately not
shipping. If you publish a package.json that points
"types" at a hand-rolled .d.ts, your consumers see whatever
you typed. If you publish without declarations, your consumers
get implicit any everywhere — and they will notice.

A working package.json for a library that uses Bun for
tests and bundling, and tsc for declarations:

{
  "name": "my-lib",
  "type": "module",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    }
  },
  "scripts": {
    "build:js": "bun build src/index.ts --outdir dist --target node",
    "build:dts": "tsc -p tsconfig.build.json --emitDeclarationOnly",
    "build": "bun run build:js && bun run build:dts",
    "typecheck": "tsc -p tsconfig.json --noEmit",
    "test": "bun test"
  }
}
Enter fullscreen mode Exit fullscreen mode

The tsconfig.build.json extends the root config and narrows
to "emitDeclarationOnly": true, "declaration": true,
"outDir": "./dist", with "noEmit": false overridden. The
root tsconfig.json keeps "noEmit": true so the editor and
bun run typecheck agree on the same diagnostics.

If you have already turned on
--isolatedDeclarations
(and you should), you can replace stage 2 with
oxc's declaration transform
and run it in parallel per file. The win compounds on a
monorepo. The shape of the pipeline doesn't change. Bun still
doesn't emit declarations. You still need a separate
declaration step. The only question is whether that step is
tsc or a parallel friend.

The CI matrix that gets you both

The reason a single Bun job is not enough is the same reason
the runtime is fast: it does not type-check. The reason a
single tsc job is not enough is that tsc does not run your
code. You want both, and you want them in parallel.

# .github/workflows/ci.yml
name: ci
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
        with: { bun-version: latest }
      - run: bun install --frozen-lockfile
      - run: bun test

  typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
        with: { bun-version: latest }
      - run: bun install --frozen-lockfile
      - run: bunx tsc --noEmit

  build:
    needs: [test, typecheck]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
        with: { bun-version: latest }
      - run: bun install --frozen-lockfile
      - run: bun run build
Enter fullscreen mode Exit fullscreen mode

Two parallel jobs at the top, one for runtime correctness
(bun test), one for type correctness (tsc --noEmit). They
race. On a small library, both finish in well under a minute.
Type errors and runtime errors surface together. The build
job runs only after both pass.

For a library that needs to work on Node and Bun, add a
matrix dimension to the test job:

  test:
    strategy:
      matrix:
        runtime: [node, bun]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
      - if: matrix.runtime == 'node'
        uses: actions/setup-node@v4
        with: { node-version: '22' }
      - run: bun install --frozen-lockfile
      - if: matrix.runtime == 'bun'
        run: bun test
      - if: matrix.runtime == 'node'
        run: node --test
Enter fullscreen mode Exit fullscreen mode

The cost is a second test job. The win is that you find out
the moment your code accidentally relies on a Bun-only
behavior — a Bun.file() call that slipped in, a require
shape Bun forgives and Node does not, an ESM/CJS edge case.
For a library, that signal is worth the runner minutes.

The typecheck job is the one that does the work bun test
will not do. It runs tsc --noEmit against the same
tsconfig.json your editor uses. If a contributor opens a
PR that returns Promise<User> where the caller expects
User, this job catches it. If the same PR's tests happen to
not exercise the broken path (because the team is using
hand-rolled mocks, or the assertion is loose), bun test
will not catch it. The second job exists for that case.

Two-lane CI: bun test for runtime + speed, tsc --noEmit for type-correctness, both gates merge

When Bun replaces tsc-watch entirely

For an application — not a library — that runs only on Bun in
production, the answer is simpler. You replace tsc-watch and
keep one parallel bunx tsc --noEmit running in another
terminal (or as a pre-commit hook, or as a CI gate). The dev
loop is bun --hot run src/index.ts. The type-check loop is
the editor's TypeScript server plus a periodic tsc --noEmit.
There is no compile step in between.

For a library — anything that publishes .d.ts and runs in
runtimes you don't control — Bun replaces tsc for execution
and bundling, but not for declaration emit and not for the
strict cross-runtime contract. You still want tsc (or an
isolated-declarations equivalent) on the publish path.

For a monorepo with project references, the tsc -b --noEmit
job in CI is the only thing keeping cross-package type
correctness honest. Bun does not look at project references.
The references graph is what tells you a change in package A
is type-incompatible with package B before the broken
publish lands. Keep that job.

The rule that fits on a sticky note: Bun runs your
TypeScript. tsc checks it.


If this was useful

The CI shape above — Bun for the dev loop and the bundler,
tsc for the type gate and the declarations — is one of the
build-pipeline patterns TypeScript in Production covers
alongside dual ESM/CJS publishing, project references,
--isolatedDeclarations, and the JSR/Node/Bun publishing
dance. If you ship a TypeScript package and you've been
nudging the build off tsc toward Bun, that's the volume in
the set with the runtime-by-runtime breakdown.

For the language itself — types, narrowing, modules, async,
the daily-driver tooling — TypeScript Essentials is the
entry point. The TypeScript Type System picks up where
Essentials ends with the generics, mapped types, and infer
patterns library APIs are built from. From JVM, Kotlin and
Java to TypeScript
makes the bridge; from PHP 8+, PHP to
TypeScript
covers the same ground from the other side.

The five-book set:

  • TypeScript Essentials — From Working Developer to Confident TS, Across Node, Bun, Deno, and the Browser — entry point: amazon.com/dp/B0GZB7QRW3
  • The TypeScript Type System — From Generics to DSL-Level Types — deep dive: amazon.com/dp/B0GZB86QYW
  • Kotlin and Java to TypeScript — A Bridge for JVM Developers — bridge for JVM devs: amazon.com/dp/B0GZB2333H
  • PHP to TypeScript — A Bridge for Modern PHP 8+ Developers — bridge for PHP devs: amazon.com/dp/B0GZBD5HMF
  • TypeScript in Production — Tooling, Build, and Library Authoring Across Runtimes — production layer: amazon.com/dp/B0GZB7F471

All five books ship in ebook, paperback, and hardcover.

The TypeScript Library — the 5-book collection

Top comments (0)