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.

esbuild transform pipeline for TypeScript stripping A source string with an explicit loader passes through the parser and emits stripped JS, or fails fast on a missing loader. Source string const x: string = '' interface, JSX... transform(opts) loader: 'ts' | 'tsx' target, jsx flags Stripped JS const x = '' + result.warnings Missing loader → defaults to 'js' → fails at the lexer ERROR: Unexpected ":" / Unexpected "interface" esbuild does not infer type from the input string transform never reads tsconfig.json or emits .d.ts target, jsx, jsxFactory, useDefineForClassFields must be passed by hand type-check separately with tsc --noEmit in CI
Figure: transform strips syntax only — supply the loader explicitly or the parser rejects TypeScript tokens at the lexical stage.

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.

transform() ignores tsconfig entirely transform needs no tsconfig.json and reads none, so target, jsx, jsxFactory and useDefineForClassFields must be passed by hand in the options object. tsconfig.jsonread by transform: none options passed by handtarget, jsx, useDefineForClassFields Your tsconfig flags do NOT carry into transform()
Figure: the central gotcha — none of your 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 declaration
  • ERROR: Invalid value for the "jsx" option when processing .tsx without loader: '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.

Missing loader defaults to js and the lexer rejects TS Without an explicit loader, transform defaults to loader js, so the parser runs in strict JavaScript mode and rejects TypeScript tokens like a colon annotation or interface at the lexical stage; setting loader ts or tsx fixes it. no loaderdefaults to 'js' Unexpected ":"lexer rejects TS loader: 'ts' | 'tsx'strips cleanly esbuild never infers the type from the input string
Figure: the rejection is lexical — an explicit loader is the only thing that leaves strict-JS mode.

Diagnosis workflow

Four-step transform diagnosis Reproduce the throw with no loader, confirm the loader is the cause by adding loader ts, check the target since a low target downlevels modern syntax, then inspect result.warnings and the thrown errors array for file, line and column. 1 reproducethrows 2 add loaderpasses now? 3 check targetesnext 4 warningsline/column
Figure: step 2 isolates the loader as the cause; step 3 rules out a downleveling target.
  1. 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";');
  2. 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 survives loader: 'ts' usually means the input is genuinely invalid syntax, or that you are feeding .tsx content and need 'tsx'. Confirm by copying the failing string into a .ts file and running npx tsc --noEmit on it — if tsc also rejects it, esbuild was right and the source is broken.

  3. 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 — set target: 'esnext' for pure type erasure. The mechanism is that target does double duty: it gates which syntax esbuild accepts and which it rewrites on output. With target: 'es2015', a #private field 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), esnext keeps the transform to a pure deletion and makes its output diffable against the source.

  4. Inspect result.warnings. Parse-time issues land in the thrown error.errors array; non-fatal issues land in result.warnings. Log both with location.file, location.line, and location.column. The split matters operationally: errors throw and stop the transform, so they arrive on the catch path as err.errors; warnings never throw, so a call can succeed, return valid code, 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:

  1. No auto-inference — the API does not infer file type from the input string.
  2. Config bypasstransform ignores tsconfig.json, so target, jsx, and useDefineForClassFields must be passed manually.
  3. Downleveling conflicts — a low target rewrites or rejects modern TS features.
  4. Missing JSX flags — omitting jsx/jsxFactory/jsxFragment when stripping .tsx yields 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

Four options plus structured error handling Pick the loader from the extension, set target esnext to avoid downleveling, set useDefineForClassFields explicitly since tsconfig is ignored, choose the jsx mode, and wrap the call to read the errors array with file, line and column on failure. loader ts/tsxfrom extension target esnextno downlevel useDefineForClassFields jsx modepreserve/automatic catch → err.errors[].locationfile : line : column
Figure: four options that 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.

Two independent responsibilities: strip and type-check The transform call strips types and emits plain JavaScript but performs no type-checking, so a separate tsc noEmit pass in CI is what actually catches a wrong annotation; the two run in parallel and neither replaces the other. esbuild transform()strips types → emits JS tsc --noEmitthe only type gate + stripping ≠ checking — run both in CI
Figure: transform() never type-checks, so a wrong annotation strips cleanly and only the separate tsc pass fails.

Gotchas & edge cases

Four transform edge cases transformSync blocks the event loop so reserve it for CLI scripts; isolatedModules semantics apply so const enum and type-only re-exports may not strip; decorators need experimentalDecorators via tsconfigRaw; source maps are opt-in via the sourcemap option. transformSync blocksCLI/init only — await in servers isolatedModulesconst enum / type re-export decoratorsneed tsconfigRaw inline source maps opt-insourcemap: 'inline' | 'external'
Figure: four defaults that surprise teams moving from the file-based build to per-string transform().
  • transformSync blocks the event loop. Reserve it for one-off CLI scripts and build-init phases. In a dev server or plugin, always await esbuild.transform(...) so concurrent requests are not serialized. The reason is the RPC model described above: transformSync blocks 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 async transform lets 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 to await transform and watching tail latency flatten.
  • isolatedModules semantics apply. Because each call sees one file, a const enum or a type-only re-export cannot be resolved across modules and may not strip as expected — prefer regular enum or pass loader: 'ts' with explicit import type. The root cause is that const enum requires 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 runtime enum object or errors. The same single-file blindness means export { Foo } where Foo is a type cannot be distinguished from a value re-export by esbuild’s local heuristics — mark it export type { Foo } so the intent is in the syntax and the line strips deterministically. Confirm by checking that no stray enum object or dangling re-export survives in the emitted code.
  • Decorators need the right target. Legacy experimentalDecorators emit only when tsconfigRaw is supplied inline, since the real tsconfig.json is ignored. Pass tsconfigRaw: { 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 __decorate helper — 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 .ts file. 'inline' bakes the map into a //# sourceMappingURL data comment (simplest for a loader); 'external' returns it separately as result.map for you to write or serve. Set sourcefile alongside 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.