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.
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.
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.
Diagnosis workflow
-
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. -
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. -
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.
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
// 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.
Gotchas & edge cases
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.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.- Decorators need the right target. Legacy
experimentalDecoratorsemit only whentsconfigRawis supplied inline, since the realtsconfig.jsonis ignored. - 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.
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.