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.

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.

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.

Diagnosis workflow

  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.

  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.

  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.

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

// 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

  • 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.
  • 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.
  • Decorators need the right target. Legacy experimentalDecorators emit only when tsconfigRaw is supplied inline, since the real tsconfig.json is 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.