Choosing a TypeScript runtime used to mean choosing a build pipeline: compile with tsc or a bundler, then run JavaScript on Node.js. In 2026 the three mainstream server-side runtimes, Node.js, Deno and Bun, all run .ts files directly. That convenience has made the choice look like a matter of taste or benchmark charts. It is not. The decision is mostly settled by a few hard constraints: where the code will run, which dependencies it needs, what security model you want, and how much operational risk your team can absorb.

This guide explains what each runtime actually does with TypeScript, gives a decision procedure ordered by those constraints, works through an example team's evaluation, and shows how to write TypeScript that stays portable so the decision is cheap to revisit. Version facts are as of October 2026; runtimes move quickly, so re-check the specifics before you commit.

Advertisement

First principles: no runtime executes types

A JavaScript runtime is an engine plus a standard library plus an event loop. Node.js and Deno embed Google's V8 engine; Bun embeds Apple's JavaScriptCore. None of these engines understands TypeScript. When a runtime "runs TypeScript", it removes or rewrites the type syntax to produce JavaScript, then runs that. There are two ways to do it. Type stripping deletes annotations and replaces them with whitespace, which is fast and keeps line and column numbers intact so stack traces need no source maps. Transpiling rewrites code, which is needed for TypeScript features that generate runtime code, such as enum, namespaces containing values, and constructor parameter properties.

In neither case is anything type-checked at run time. A program with type errors runs until the error becomes a JavaScript failure. Type checking is a separate step performed by the TypeScript compiler. That separation is the most important idea in this guide: your runtime choice and your type-checking setup are independent decisions, and you need both.

The three candidates in October 2026

Node.js added built-in type stripping as an experiment and has since made it the default: it is enabled by default from v23.6.0 and v22.18.0, and the documentation marks it stable as of v25.2.0 and v24.12.0. It supports only erasable syntax. Enums, namespaces with runtime code, parameter properties and import aliases raise errors, and the separate --experimental-transform-types flag that handled some of them was removed in v26.0.0. Imports must use explicit .ts extensions, type-only imports must say import type, .tsx is not supported, Node ignores tsconfig.json entirely, and it refuses to strip .ts files inside node_modules, so published packages must still ship JavaScript. Node's strengths are unchanged: the widest library and platform support, native addons, and a published long-term-support schedule; from Node.js 27 the project moves to one major release a year, each of which becomes an LTS release.

Deno has run TypeScript natively from the start, transpiling the full language. Since Deno 2 (October 2024) it supports package.json, node_modules and npm: specifiers, so most npm packages work. Its distinguishing feature is security: programs have no file, network or environment access unless granted with flags such as --allow-net or --allow-read. It bundles a formatter, linter, test runner, type checker (deno check) and deno compile for single-file executables. Note that deno run does not type-check by default.

Bun runs TypeScript and JSX by transpiling, including the non-erasable features, and reads tsconfig.json for settings like path aliases. It aims to be a drop-in Node.js replacement and bundles a package manager, test runner and bundler, with bun build --compile for executables. It does not type-check. Its draw is speed of startup, installs and test runs; its risk is that Node compatibility, while broad, is reimplemented rather than inherited, so edge cases in less common Node APIs and native addons are where surprises live.

Advertisement

The decision procedure

Order the questions from hardest constraint to softest preference. Benchmarks come last because they rarely override the earlier answers and because published comparisons seldom resemble your workload.

Choosing a TypeScript runtime: hard constraints first, preferences lastWhere will it run?platform supportNative addons orNode-only APIs?noyesNode.jssafest compatibilityNeed sandboxedpermissions by default?yesDenodeny-by-default I/OnoIs startup or installspeed the bottleneck?yes, measuredBunpilot behind a canarynoNode.js LTSdefault choiceIn every case:type-check separately (tsc/tsgo)write erasable syntax onlyrun CI on the target runtimepin the version you deployPlatform support and dependency compatibility are hard constraints; benchmarks are tie-breakers
Hard constraints come first: platform support, then dependency compatibility, then security model. Raw speed only decides between options that survive those questions.
  1. Where will it run? Managed platforms decide for you. Serverless functions, PaaS buildpacks and edge platforms each support specific runtimes, and anything else needs a container or custom runtime that you then maintain. Check the platform documentation for the exact versions supported, as with the managed runtimes described in the AWS Lambda article.
  2. What do your dependencies need? List native addons (database drivers, image libraries, crypto bindings), packages that patch Node internals (APM agents, some test tools), and anything using less common Node APIs. Each of these is a compatibility test, not an assumption.
  3. What security model do you want? If you run untrusted or third-party scripts, or want a hard boundary around what a build tool can touch, Deno's deny-by-default permissions are a structural advantage. Node has a permission model enabled with --permission, but it is opt-in and not how the ecosystem is used by default.
  4. What can your team operate? Consider debugging and profiling tools, APM vendor support, security advisories, and how long a version is supported. A runtime your on-call engineers cannot profile at 3 a.m. is expensive regardless of its benchmarks.
  5. Only then, performance. Measure cold start, steady-state throughput and memory on your own service, and remember that most API latency is spent waiting on databases and networks.

Worked example: an API team evaluating all three

A team runs a TypeScript HTTP API and two queue workers in containers on Kubernetes, uses PostgreSQL through a pure-JavaScript driver, an image-resizing library with a native addon, and a vendor APM agent. Their CI takes 14 minutes, a third of it installing dependencies and running tests. They score the runtimes against their actual constraints:

CriterionNode.js LTSDenoBun
Runs on their platform (containers)YesYesYes
Native image addonSupportedTest requiredTest required
Vendor APM agentOfficially supportedCheck vendor docsCheck vendor docs
Existing enums and parameter propertiesMust be rewrittenWorkWork
Install and test speed in CIBaselineMeasureMeasure; often the reason teams try it
Permission sandboxingOpt-inDefaultNo equivalent

The result is a split decision that is common in practice. Production services stay on Node.js LTS because of the APM agent and native addon, and the team adopts erasable syntax so Node can run their sources directly in development without a build step. Separately, they trial Bun as the test runner and package manager in CI, behind a job that also runs the suite on Node, because that is where their measured pain is. Neither choice is locked in, because the code itself stays portable.

Write TypeScript that stays portable

The cheapest way to keep the decision reversible is to write the subset of TypeScript every runtime handles identically. TypeScript 5.8 added the erasableSyntaxOnly option, which makes the compiler reject enums, runtime namespaces and parameter properties. Combined with the options Node's documentation recommends, a portable configuration looks like this:

// tsconfig.json
{
  "compilerOptions": {
    "noEmit": true,                          // the runtime runs the .ts; tsc only checks
    "target": "esnext",
    "module": "nodenext",
    "erasableSyntaxOnly": true,             // no enum, runtime namespace, parameter properties
    "verbatimModuleSyntax": true,           // forces `import type` for type-only imports
    "rewriteRelativeImportExtensions": true,// allow ./x.ts imports when you do emit
    "strict": true
  },
  "include": ["src"]
}

Replace each enum with a constant object and a derived union type, which is erasable and works everywhere:

// before: enum Status { Active = "active", Disabled = "disabled" }
export const Status = { Active: "active", Disabled: "disabled" } as const;
export type Status = (typeof Status)[keyof typeof Status];

// before: constructor(private readonly db: Db) {}
export class UserRepo {
  readonly db: Db;
  constructor(db: Db) { this.db = db; }
}

import type { Db } from "./db.ts";   // type-only import, explicit extension

Avoid relying on tsconfig path aliases at run time, since Node ignores them; use package.json imports entries such as #lib/*, which Node and Bun resolve; Deno projects usually declare the same mapping as an import map in deno.json, and its handling of package.json subpath patterns is more limited, so test it there. Keep runtime-specific APIs such as Deno.serve or Bun.serve behind a small adapter module so the rest of the code uses web-standard Request and Response objects.

Type checking and CI

Because no runtime checks types, type checking must be a required CI step. Run tsc --noEmit (or deno check for Deno projects). TypeScript 7.0, the native compiler ported to Go, reached general availability in July 2026 and is reported to type-check large projects many times faster; at release it lacked a stable programmatic API, so tools that embed the compiler, such as some linters and framework template checkers, may still need TypeScript 6 alongside it. Verify your toolchain before switching. Then run the test suite on every runtime you claim to support:

# .github/workflows/ci.yml (excerpt)
jobs:
  typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 24 }
      - run: npm ci && npx tsc --noEmit
  test:
    needs: typecheck
    runs-on: ubuntu-latest
    strategy:
      matrix: { runtime: [node, bun, deno] }
    steps:
      - uses: actions/checkout@v4
      - if: matrix.runtime == 'node'
        uses: actions/setup-node@v4
        with: { node-version: 24 }
      - if: matrix.runtime == 'bun'
        uses: oven-sh/setup-bun@v2
      - if: matrix.runtime == 'deno'
        uses: denoland/setup-deno@v2
      - run: ./scripts/test-${{ matrix.runtime }}.sh

Pin the runtime version in the matrix to the version you deploy, and update both together. A smoke test per runtime that starts the real entry point, makes one request and exits catches most startup-level incompatibilities in seconds.

Failure modes when switching

FailureWhere it bitesPrevention
Enum or parameter property in a .ts fileNode refuses to run the fileerasableSyntaxOnly in tsconfig; lint in CI
Type imported without import typeRuntime import error under Node strippingverbatimModuleSyntax
Extensionless relative importsModule not found under NodeExplicit .ts extensions
Path aliases from tsconfigWork in Bun and bundlers, fail in Nodepackage.json imports field
Native addon or APM agent incompatibilityCrash at startup or silent missing telemetryRun the real service under the candidate in staging
Different lockfiles per package managerDrift between CI and production installsOne package manager and lockfile of record
Missing permission flags under DenoPermissionDenied at first I/ODeclare permissions in deno.json tasks; test the real command

Treat a runtime switch like any other migration: shadow traffic or a canary first, a clear rollback, and metrics compared on the same dashboards. The migration guide and canary release guide cover the rollout mechanics.

Trade-offs in one place

Node.js trades some developer convenience, such as the erasable-syntax restriction and the absence of built-in formatting and linting, for the least risk: everything supports it, and its LTS process is well understood. Deno trades some ecosystem edge cases for the strongest default security posture and an integrated toolchain, which is especially attractive for scripts, internal tools and new services with few native dependencies. Bun trades some compatibility certainty for speed and an all-in-one toolchain; it is lowest-risk in development tooling, where a failure is a red CI job rather than an outage. Many teams end up using more than one, and portable TypeScript is what makes that sustainable. For the broader skills map these choices sit in, see the backend engineer roadmap.

What to do next

  1. List your deployment platforms and the exact runtime versions each supports; cross off any runtime that would force a custom runtime you do not want to maintain.
  2. Inventory native addons, APM agents and packages that patch Node internals, and test each under every remaining candidate.
  3. Enable erasableSyntaxOnly and verbatimModuleSyntax, replace enums and parameter properties, and switch to explicit .ts imports.
  4. Make tsc --noEmit (or deno check) a required CI step, and evaluate TypeScript 7 once your linters and framework tools support it.
  5. Add a CI matrix that runs tests and a startup smoke test on each runtime you might use, pinned to deployed versions.
  6. Pilot an alternative runtime first in developer tooling or CI, then behind a canary in production with a tested rollback.
Key takeaway: No runtime executes types: Node.js strips them, Deno and Bun transpile them, and all three leave type checking to a separate compiler step you must run in CI. Pick by hard constraints in order, platform support, then dependency compatibility, then security model, then what your team can operate, and use benchmarks only to break ties. Write erasable, explicitly imported TypeScript so the choice stays reversible, and default to Node.js LTS for production unless a measured need or a constraint points elsewhere.