Using esbuild Transform API for TypeScript Stripping
You need to erase TypeScript syntax from a single source string fast — inside a framework loader, an HMR pipeline, or a test runner — without bundling, resolving imports, or running a type-checker. This guide covers the esbuild.transform() API for exactly that. It sits one level under the esbuild API and CLI for Rapid Builds overview; read that for how transform() differs from build() and context().
The transform method provides a stateless pathway for stripping TypeScript syntax without invoking dependency resolution or bundling. Unlike build, which constructs a module graph and resolves imports, transform operates strictly on isolated source strings. This makes it the optimal primitive for framework plugin pipelines, custom loaders, and hot-module-replacement workflows. It bypasses the file system entirely, treating input as raw text that must be explicitly annotated before the lexer engages.
The reason this problem exists at all is that TypeScript’s type syntax is erasable by design: annotations, interface and type declarations, and import type statements have no runtime representation, so producing runnable JavaScript is a pure deletion pass over the token stream. A full type-checker builds a symbol table across every file it can reach, resolves generic constraints, and reports diagnostics — work that is measured in seconds and that a request-scoped loader cannot afford on every keystroke. transform() deliberately does none of that. It parses one file, walks the AST once deleting type nodes, and prints the surviving JavaScript. There is no cross-file knowledge, no tsconfig discovery, and no cache to warm; each call is independent and typically completes in well under a millisecond for a component-sized file.
That independence is exactly what makes it fit the middle of a build pipeline rather than the ends. A dev server receives a .ts file over HTTP, hands the raw string to transform(), and streams plain JavaScript back to the browser before Vite or the framework runtime resolves the next import. A test runner does the same per module as Node requests it. If you skip the stripping step, the browser or Node’s ESM loader receives a const x: string and throws SyntaxError: Unexpected token ':' at parse time — the exact failure this API is built to prevent. Because the step is stateless, the cost of getting its options wrong is not a corrupted cache but a per-request throw, which is easy to reproduce and easy to fix once you know the loader and target rules below.
Prerequisites & reproducible setup
# esbuild 0.25.x, Node 20+
mkdir ts-strip && cd ts-strip
npm init -y
npm pkg set type=module
npm install --save-dev esbuild@0.25
You need esbuild 0.25.x and Node 20+. No tsconfig.json is required — and that is the central gotcha, because transform() ignores it entirely. The type=module line matters for the reproduction scripts below: with it set, a .mjs or .js file can use top-level await and import * as esbuild, which the async examples rely on. Pin esbuild to a single minor (esbuild@0.25) rather than a caret range, because pre-1.0 esbuild treats minor bumps as its breaking-change channel — option names and default target behavior have shifted between minors, and a loader pipeline that silently changes its class-field semantics under you is far harder to debug than a locked version you upgrade deliberately.
How it works under the hood
transform() is a thin RPC wrapper. The JavaScript you import is a client; the actual parsing runs in a long-lived Go child process (or a WebAssembly instance in the browser build) that esbuild spawns once and reuses. When you call transform, the client serializes your source string and options and ships them over stdin to that process; the Go side parses, strips, prints, and ships back { code, map, warnings }. This is why the first call in a process carries a few milliseconds of startup latency and every subsequent call is near-instant — the expensive part is spawning the child, not the transform itself. It is also why you should never spin up a fresh Node process per file in a hot loop: you pay the spawn cost each time and throw away the warm worker.
Inside that process the pipeline is fixed and shallow. The lexer tokenizes the string under the language implied by the loader — and this is the single most important mechanical fact about the API. loader: 'ts' puts the lexer in a mode where :, interface, as, and generic angle brackets are grammar; loader: 'js' (the default) treats those same characters as ordinary JavaScript operators and throws the moment it meets one it cannot place. The parser then builds an AST, and a print pass emits code while simply not writing any node tagged as type-only. There is no separate “type removal” phase you can hook — stripping is a side effect of never printing the erasable nodes. Because the parser never resolves an import or reads another file, anything that depends on cross-module knowledge (whether a named import is a type or a value, for instance) is decided by local syntax and heuristics alone, which is the root of the isolatedModules edge cases discussed later.
tsconfig flags apply; pass them in the options object.Exact Error: Unexpected token and Loader Misconfiguration
The most frequent failure occurs when developers invoke transform() without explicitly defining the loader property. esbuild defaults to loader: 'js', which immediately triggers one of the following:
Transform failed with 1 error: <stdin>:1:7: ERROR: Unexpected ":"ERROR: Unexpected "interface"when the input opens with a type declarationERROR: Invalid value for the "jsx" optionwhen processing.tsxwithoutloader: 'tsx'
This stems from the API’s design: it does not infer file extensions from the input string. Without an explicit type declaration in the options object, the parser runs in strict JavaScript mode. TypeScript-specific syntax such as interface, type aliases, parameter annotations, or JSX fragments is then rejected as invalid tokens — at the lexical stage, before any semantic analysis.
The consequence of the lexical timing is worth internalizing, because it changes how you read the error. The column number in <stdin>:1:7 points at the first character the JavaScript grammar cannot accept, not at the root cause. For const x: string, that is the colon at column 7; for function f<T>(x: T), it is the < where the grammar expected a (. Neither message mentions TypeScript, loaders, or configuration — esbuild has no way to know you meant TypeScript, so it reports the failure in terms of the language it was told to parse. Teams new to the API routinely misread this as a bug in their source and start editing the annotation, when the correct fix is one option flag away. Once you have seen the pattern, Unexpected ":" / Unexpected "interface" / Unexpected "<" at column-of-first-annotation is an unmistakable signature for “the loader is missing or wrong”.
There is a second, subtler form of the same class of error. If you pass .tsx content with loader: 'ts' instead of 'tsx', the lexer is in TypeScript mode but treats <Foo> as a type assertion, not a JSX element, and either produces mangled output or throws further into the file where the assertion grammar collapses. The rule is mechanical: .ts never allows angle-bracket JSX (because it needs <T> for casts and generics), and .tsx trades that cast syntax away to make room for JSX. Pick the loader from the actual file extension, never from a guess about the content.
loader is the only thing that leaves strict-JS mode.Diagnosis workflow
target.-
Reproduce the throw. A minimal script fails synchronously:
// esbuild 0.25.x, Node 20+ import * as esbuild from 'esbuild'; // Fails: defaults to loader 'js', rejects the ':' annotation esbuild.transformSync('const x: string = "test";'); -
Confirm the loader is the cause. Add
{ loader: 'ts' }and re-run; if it now passes, the original omission was the bug. This is a genuine bisection step, not a formality: if adding the loader does not clear the throw, the problem is not the loader and you have saved yourself from changing the wrong option. A throw that survivesloader: 'ts'usually means the input is genuinely invalid syntax, or that you are feeding.tsxcontent and need'tsx'. Confirm by copying the failing string into a.tsfile and runningnpx tsc --noEmiton it — iftscalso rejects it, esbuild was right and the source is broken. -
Check the target. A mismatched
target: 'es2015'downlevels modern syntax (optional chaining, nullish coalescing, private fields) and can surface as a transform error rather than a clean strip — settarget: 'esnext'for pure type erasure. The mechanism is thattargetdoes double duty: it gates which syntax esbuild accepts and which it rewrites on output. Withtarget: 'es2015', a#privatefield or a??operator is not just passed through — esbuild lowers it into helper code or older constructs, so your “type stripping” pass quietly becomes a transpile-down pass and the diff between input and output is far larger than the deleted annotations. If your only goal is erasing types and you plan to downlevel later in the pipeline (or not at all),esnextkeeps the transform to a pure deletion and makes its output diffable against the source. -
Inspect
result.warnings. Parse-time issues land in the thrownerror.errorsarray; non-fatal issues land inresult.warnings. Log both withlocation.file,location.line, andlocation.column. The split matters operationally:errorsthrow and stop the transform, so they arrive on thecatchpath aserr.errors;warningsnever throw, so a call can succeed, return validcode, and still have flagged something worth surfacing — an unused@ts-expect-error, a suspicious cast — that you will miss entirely if you only inspect the return value on the happy path. In a loader, forward warnings to the framework’s diagnostic channel rather than dropping them; a silently discarded warning is how a real problem ships to production behind a green build.
Root-cause summary:
- No auto-inference — the API does not infer file type from the input string.
- Config bypass —
transformignorestsconfig.json, sotarget,jsx, anduseDefineForClassFieldsmust be passed manually. - Downleveling conflicts — a low
targetrewrites or rejects modern TS features. - Missing JSX flags — omitting
jsx/jsxFactory/jsxFragmentwhen stripping.tsxyields invalid output for non-automatic React setups.
For where this isolated transform fits into larger dependency graphs, see esbuild & Turbopack Workflows.
The complete annotated solution
tsconfig would normally supply, plus structured error extraction on the catch path.// esbuild 0.25.x, Node 20+
import * as esbuild from 'esbuild';
async function stripTypes(source, filename) {
try {
const result = await esbuild.transform(source, {
// 1. Pick the loader from the extension — never let it default to 'js'
loader: filename.endsWith('.tsx') ? 'tsx' : 'ts',
// 2. esnext prevents unintended downleveling during pure stripping
target: 'esnext',
// 3. tsconfig is ignored, so set class-field semantics explicitly
useDefineForClassFields: true,
// 4. preserve JSX if a later tool compiles it; use 'automatic' for React 17+
jsx: 'preserve',
sourcefile: filename,
});
for (const w of result.warnings) {
console.warn(`${w.location?.file}:${w.location?.line} ${w.text}`);
}
return result.code;
} catch (err) {
// 5. Structured error extraction from the errors array
const details = (err.errors ?? [])
.map((e) => `${e.location?.file ?? '<stdin>'}:${e.location?.line}:${e.location?.column} - ${e.text}`)
.join('\n');
throw new Error(`esbuild transform failed:\n${details}`);
}
}
const out = await stripTypes('interface P { id: number }\nconst p: P = { id: 1 };\nexport { p };', 'demo.ts');
console.log(out);
Run it with node strip.mjs. The interface declaration and the : P annotation are erased; export { p } survives untouched.
Verification
Expected stdout from the script above:
const p = { id: 1 };
export { p };
The interface line is gone and no annotations remain. To confirm type safety is handled elsewhere, run npx tsc --noEmit against the original source in CI — transform() performs no type-checking, so a wrong annotation strips cleanly but only tsc catches it.
A second worked example: a .tsx component
The .ts case erases annotations and declarations; the .tsx case adds the JSX decision, which is where the jsx option earns its keep. The block below strips a small React component two ways and shows why the mode you pick changes the emitted output rather than just its formatting.
// esbuild 0.25.x, Node 20+
import * as esbuild from 'esbuild';
const source = `
type Props = { label: string };
export function Badge({ label }: Props) {
return <span className="badge">{label}</span>;
}
`;
// Mode A: preserve — leave JSX intact for a later compiler (e.g. Vite's React plugin)
const preserved = await esbuild.transform(source, {
loader: 'tsx',
jsx: 'preserve',
target: 'esnext',
});
// Mode B: automatic — esbuild itself lowers JSX to jsx-runtime calls, no React import needed
const automatic = await esbuild.transform(source, {
loader: 'tsx',
jsx: 'automatic',
target: 'esnext',
});
console.log('--- preserve ---\n' + preserved.code);
console.log('--- automatic ---\n' + automatic.code);
With jsx: 'preserve' the <span> survives verbatim and type Props plus the : Props annotation are the only things removed, so a downstream tool still has real JSX to compile. With jsx: 'automatic' esbuild lowers the same element into a jsx("span", …) call and injects the react/jsx-runtime import for you, producing runnable output with no import React in sight. Choosing 'preserve' when nothing downstream compiles JSX ships un-runnable markup to the browser; choosing 'automatic' when a later plugin also transforms JSX double-compiles it. Match the mode to exactly one JSX-lowering owner in the pipeline.
transform() never type-checks, so a wrong annotation strips cleanly and only the separate tsc pass fails.Gotchas & edge cases
transform().transformSyncblocks the event loop. Reserve it for one-off CLI scripts and build-init phases. In a dev server or plugin, alwaysawait esbuild.transform(...)so concurrent requests are not serialized. The reason is the RPC model described above:transformSyncblocks the Node thread until the Go worker answers, so two simultaneous requests run head-to-tail instead of overlapping, and under load the synchronous variant turns a sub-millisecond transform into a queue. The asynctransformlets the event loop keep accepting connections while the worker chews through the batch. Symptom of getting this wrong: dev-server latency that scales linearly with concurrent open files. Confirm by swapping toawait transformand watching tail latency flatten.isolatedModulessemantics apply. Because each call sees one file, aconst enumor a type-only re-export cannot be resolved across modules and may not strip as expected — prefer regularenumor passloader: 'ts'with explicitimport type. The root cause is thatconst enumrequires the compiler to inline member values at every use site, which needs the declaring file; a single-file transform has no way to reach it, so esbuild either leaves a runtimeenumobject or errors. The same single-file blindness meansexport { Foo }whereFoois a type cannot be distinguished from a value re-export by esbuild’s local heuristics — mark itexport type { Foo }so the intent is in the syntax and the line strips deterministically. Confirm by checking that no strayenumobject or dangling re-export survives in the emittedcode.- Decorators need the right target. Legacy
experimentalDecoratorsemit only whentsconfigRawis supplied inline, since the realtsconfig.jsonis ignored. PasstsconfigRaw: { compilerOptions: { experimentalDecorators: true } }in the options object; without it, decorator metadata is dropped and frameworks that rely on it (Angular, NestJS, TypeORM) fail at runtime with missing-metadata errors that give no hint they trace back to a stripping step. Confirm by grepping the output for the__decoratehelper — its absence when you expected decorators means the flag never reached the transform. - Source maps are opt-in. Pass
sourcemap: 'inline'or'external'; otherwise the stripped output has no mapping back to the TS source and stack traces point at transformed line numbers. Since stripping deletes lines, the transformed line numbers drift from the originals — a crash reported at “line 12” of the output may be line 20 of your.tsfile.'inline'bakes the map into a//# sourceMappingURLdata comment (simplest for a loader);'external'returns it separately asresult.mapfor you to write or serve. Setsourcefilealongside it so the map names the real path instead of<stdin>.
Performance considerations
The headline number — sub-millisecond per file — holds only if you keep the worker warm and avoid accidental work. The single biggest waste is per-file process spawning: a script that shells out to node -e "esbuild.transformSync(...)" once per file pays the cold-start cost every time and can run orders of magnitude slower than one long-lived process that calls transform() in a loop. Import esbuild once, let it spawn its Go worker once, and reuse that instance for the life of the server or CLI run.
The second lever is target. A pure strip at target: 'esnext' is a deletion pass; a strip at target: 'es2015' additionally lowers optional chaining, nullish coalescing, class fields, and async syntax into helper-laden equivalents, which costs both time and output size. If a later stage already downlevels for you, doing it here as well is duplicated work whose second pass is wasted. Measure with a tight loop over a representative file and compare esnext against your real target before assuming the transform is the bottleneck — more often the surrounding I/O, not the strip, dominates.
For batch workloads (transforming a whole src/ directory once, say in a prebuild step), prefer await with Promise.all over a serial loop of transformSync. The async API hands each request to the shared worker and lets esbuild pipeline them, so a few hundred files complete in the time a synchronous loop would spend on a fraction of them. Reserve transformSync for the case where there is genuinely one file and the surrounding code cannot be async, such as a top-level config shim.
When not to use this
Reach for transform() only when the unit of work is a single, already-loaded string and you do not need cross-file resolution. If you need to bundle, resolve node_modules, split chunks, or emit a dependency graph, use build() or context() instead — feeding files to transform() one at a time and stitching the outputs by hand reimplements a worse bundler. If you need real type errors to fail the build, transform() is the wrong tool by construction: it never type-checks, so pair it with tsc --noEmit rather than expecting it to catch a mistyped annotation. And if every file in your project is TypeScript destined for the same output settings, a file-based build with an explicit tsconfig is simpler than hand-threading loader, target, and jsx through a per-string call — transform() pays off precisely when the string arrives from somewhere the file system does not own, such as an in-memory HMR payload or a virtual module.
Comparison with tsc and other strippers
Against tsc, the trade is speed for safety: tsc builds a project-wide type graph and reports errors but emits in seconds; esbuild’s transform() erases syntax in under a millisecond and reports nothing about types. They are complements, not substitutes — the standard pattern is esbuild (or a native stripper) for emit and tsc --noEmit for the gate. TypeScript 5.8’s own --erasableSyntaxOnly and Node’s native type stripping enforce the same erasable-only subset esbuild strips, which is worth noting because code that relies on enum runtime objects or experimentalDecorators sits outside that subset and behaves differently across all of these tools.
Against swc and sucrase, all three do single-file, type-unaware stripping and all three are fast enough that the choice rarely comes down to raw throughput. esbuild’s advantage in a build-tooling stack is that you are usually already running it for bundling, so transform() shares the same warm worker, option vocabulary, and error shape as the rest of your pipeline — one dependency, one mental model. Sucrase leans further toward “fastest possible dev-time strip with no bundling ambitions”; swc brings a plugin system and a full transform pipeline. If esbuild is already in the tree, transform() is the path of least resistance and the one whose errors you already know how to read.
CI integration
The rule to encode in CI is that stripping and checking are separate jobs. The transform step (whatever loader, dev server, or prebuild invokes it) guarantees only that syntax was erased; the type gate must be an explicit tsc --noEmit step that fails the pipeline on a real type error. A common and dangerous misconfiguration is a CI that runs the esbuild-based build, sees it succeed, and ships — because the build always succeeds on erasable syntax regardless of type correctness. Add tsc --noEmit as a required check that runs in parallel with the build, keep its tsconfig as the source of truth for types, and treat the two green checks as answering different questions: “does it run” and “is it correct”. Cache tsc’s incremental build info between runs so the checker’s cost does not erase the speed the transform step bought you.
Related
- esbuild API and CLI for Rapid Builds — how transform differs from build and context.
- Reducing esbuild bundle size with minify and tree-shaking — the bundling-stage counterpart once you move past single files.
- Using esbuild context watch mode for incremental rebuilds — wrap transforms in a persistent rebuild loop.
- Custom Loaders and Asset Handling — call transform from inside an onLoad plugin hook.