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.
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.
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.
- 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.
- 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.
- 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. - 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.
- 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:
| Criterion | Node.js LTS | Deno | Bun |
|---|---|---|---|
| Runs on their platform (containers) | Yes | Yes | Yes |
| Native image addon | Supported | Test required | Test required |
| Vendor APM agent | Officially supported | Check vendor docs | Check vendor docs |
| Existing enums and parameter properties | Must be rewritten | Work | Work |
| Install and test speed in CI | Baseline | Measure | Measure; often the reason teams try it |
| Permission sandboxing | Opt-in | Default | No 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 extensionAvoid 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 }}.shPin 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
| Failure | Where it bites | Prevention |
|---|---|---|
| Enum or parameter property in a .ts file | Node refuses to run the file | erasableSyntaxOnly in tsconfig; lint in CI |
| Type imported without import type | Runtime import error under Node stripping | verbatimModuleSyntax |
| Extensionless relative imports | Module not found under Node | Explicit .ts extensions |
| Path aliases from tsconfig | Work in Bun and bundlers, fail in Node | package.json imports field |
| Native addon or APM agent incompatibility | Crash at startup or silent missing telemetry | Run the real service under the candidate in staging |
| Different lockfiles per package manager | Drift between CI and production installs | One package manager and lockfile of record |
| Missing permission flags under Deno | PermissionDenied at first I/O | Declare 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
- 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.
- Inventory native addons, APM agents and packages that patch Node internals, and test each under every remaining candidate.
- Enable erasableSyntaxOnly and verbatimModuleSyntax, replace enums and parameter properties, and switch to explicit .ts imports.
- Make tsc --noEmit (or deno check) a required CI step, and evaluate TypeScript 7 once your linters and framework tools support it.
- Add a CI matrix that runs tests and a startup smoke test on each runtime you might use, pinned to deployed versions.
- Pilot an alternative runtime first in developer tooling or CI, then behind a canary in production with a tested rollback.