TypeScript without Node.js


TypeScript

Last time I did a coding comparison between Claude and DeepSeek. The use case was implement a way to run TypeScript compiler without using Node.js. This is not an abstract use case but a genuely useful use case.

So I cleaned things up and packaged the thing into a github repo here.

tsk — Running the TypeScript Compiler Under QuickJS

tsk is a Node.js compatibility shim that lets the stock TypeScript compiler (tsc.js) run directly under QuickJS — the tiny, embeddable JS engine by Fabrice Bellard. No Node.js, no npm, no node_modules (for the compiler itself — your project can still use them).

You invoke it like this:

qjs --std -I node_shim.js typescript-5.4.5/tsc.js --noEmit src/main.ts

That's it. TypeScript's full type-checking and downlevel compilation, running in a ~3MB interpreter binary.

Why?

QuickJS is tiny, starts instantly, and is easy to bundle. If you're building a tool that needs TypeScript but doesn't want to drag in a full Node.js runtime — think CI containers, embedded systems, or single-binary distributions — this gives you a path. The shim is about 600 lines of JavaScript.

How it works

The TypeScript compiler (tsc.js) is distributed as a self-contained bundle, but it expects a Node.js environment with require('fs'), require('path'), a process global, and so on. The shim (node_shim.js) provides all of that:

Shim component Backed by
require('fs') — read/write/stat/mkdir QuickJS's std and os modules
require('path') — join/resolve/normalize Pure JS POSIX path utilities
require('os') — platform, homedir, tmpdir Small stubs + std.getenv()
require('buffer')Buffer.from(), encoding Extends Uint8Array
process — argv, env, cwd, hrtime, nextTick Hybrid of std, os, and plain JS
CommonJS module loader Walks node_modules up the tree, reads package.json "main"

When tsc.js calls require('crypto'), the shim throws — which is fine, because TypeScript falls back to its built-in generateDjb2Hash for content hashing. Incremental .tsbuildinfo still works; it just uses a different hash.

The shim also disables --watch and --generateCpuProfile with a clear error, since QuickJS doesn't have the event loop or inspector support those features require.

What about the TypeScript lib files?

The Makefile fetches tsc.js and all the .d.ts lib files from the official npm tarball:

make fetch-ts TS_VERSION=5.4.5

It extracts tsc.js plus lib.d.ts, lib.es2015.d.ts, etc. into a typescript-5.4.5/ directory alongside the shim.

Tests

There's a test suite covering both the shim components and end-to-end tsc invocations:

make test

The shim tests cover Buffer, path, fs, os, process, require, TextEncoder/TextDecoder, and btoa/atob. The tsc tests run the real compiler against TypeScript source files and verify output — valid files exit 0, type errors exit non-zero, ES5 downcompilation generates function instead of =>, JSX transforms work, enums compile correctly, etc.

What it doesn't do

  • No --watch mode — QuickJS has no event loop. Use a shell loop or an external watcher.
  • No source map support for stack traces — compiler diagnostics still work; your runtime stack traces just won't be remapped.
  • No CPU profiling--generateCpuProfile exits immediately.

The approach

The design philosophy is minimalist: provide just enough of Node.js to make tsc.js happy, and nothing more. The shim is loaded before tsc.js via QuickJS's -I (include) flag, so by the time the compiler runs, every global it expects is already in place.

Bundling a full Node.js polyfill would be overkill — this is laser-focused on one piece of software. If someone wanted to run other Node.js tools under QuickJS, they'd need additional shims, but for the TypeScript compiler specifically, this works surprisingly well.

The project also serves as a neat case study in how much of Node.js you actually need to reimplement to run a "self-contained" JS tool. The answer: not as much as you'd think.

Use cases

cases

At first glance, running tsc under QuickJS instead of Node.js might seem like a solution in search of a problem. But there are several real-world situations where this trade-off makes sense:

  1. Minimal CI/CD containers. A Node.js install (even node:alpine) is ~150MB compressed. QuickJS is ~3MB. If your CI pipeline only needs to type-check a TypeScript project — not run tests or bundle — you can cut the image size by orders of magnitude. That means faster pulls, less network, and cheaper storage. Pair it with qjs --std -I node_shim.js typescript-5.4.5/tsc.js --noEmit as a lint-style gate and you're done.
  2. Serverless cold starts. Lambda, CloudFlare Workers, and similar environments penalize large package sizes and cold-start time. Dropping Node.js from the dependency chain for a type-check step — or even for a compile step on a build server — shaves meaningful time. QuickJS starts in single-digit milliseconds.
  3. Bootstrapping / self-hosting compilers. If you're building a toolchain that targets JavaScript, and that toolchain itself is written in TypeScript, you have a chicken-and-egg problem: you need TypeScript to build the project, but you need the project built to install TypeScript the usual way. A standalone qjs + shim + tsc.js bundle breaks that cycle with no dependency on a pre-existing Node.js.
  4. Embedded and IoT. QuickJS runs on microcontrollers, OpenWrt routers, and minimal Linux systems where installing Node.js is impractical or impossible. If you need to compile TypeScript on-device — say, for a user-facing scripting layer — this gives you the full compiler in a footprint that fits.
  5. Single-binary distribution. Tools like pkg or nexe bundle Node.js into a binary, but they're heavy. A QuickJS binary with the shim and tsc.js concatenated (via -I or by embedding the shim into the binary) produces a ~5MB executable that does one thing: compile TypeScript. No node_modules, no platform detection, no ABI mismatches. Just works.
  6. Air-gapped or restricted environments. If you're shipping software into a network-isolated environment — a factory floor, a classified network, a ship — you care deeply about the number of dependencies you need to transfer and audit. A single JS file (the shim) plus one tarball (TypeScript) plus one binary (qjs) is about as lean as it gets.
  7. Cross-platform consistency. QuickJS is a deterministic, single-implementation engine. Node.js varies subtly across versions (V8 engine updates, API deprecations, platform-specific quirks). For reproducible builds — where you need the exact same compiler output regardless of what system the build runs on — eliminating Node.js from the equation removes a variable.
  8. Understanding what "Node.js" actually means to a JS tool. This is more of a meta use-case, but running tsc under QuickJS is a practical way to audit how much of Node.js a "self-contained" bundle actually touches. The answer turns out to be: require, fs, path, process, and a handful of built-in modules. The shim is a concrete map of those dependencies, which is useful if you're designing your own tools to be runtime-agnostic.

None of these replace Node.js for full-scale development — you still want a proper runtime for running tests, dev servers, and build pipelines. But for the narrow job of compiling TypeScript to JavaScript, the overhead of Node.js is hard to justify. tsk makes that visible, and gives you a practical alternative.