TypeScript without Node.js

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
--watchmode — 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 —
--generateCpuProfileexits 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

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:
- 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 withqjs --std -I node_shim.js typescript-5.4.5/tsc.js --noEmitas a lint-style gate and you're done. - 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.
- 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.jsbundle breaks that cycle with no dependency on a pre-existing Node.js. - 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.
- Single-binary distribution. Tools like
pkgornexebundle Node.js into a binary, but they're heavy. A QuickJS binary with the shim andtsc.jsconcatenated (via-Ior by embedding the shim into the binary) produces a ~5MB executable that does one thing: compile TypeScript. Nonode_modules, no platform detection, no ABI mismatches. Just works. - 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.
- 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.
- Understanding what "Node.js" actually means to a JS tool. This is more of a meta
use-case, but running
tscunder 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.