| | |

esbuild 08 ⚡ TypeScript & JSX — Transpilation without type-checking

⏱️ Estimated read: 11 min | |

esbuild treats TypeScript and JSX as syntax to be transformed, not as a type system to be verified. When it encounters a .ts or .tsx file, it parses the file, strips the type annotations, and converts the JSX elements into function calls. It does not load tsconfig.json to resolve types, it does not check that a function returns what its signature promises, and it does not validate that a prop type matches what the parent passes. The output is JavaScript that runs, regardless of whether the types were correct.

This chapter covers the two content types that esbuild handles in this transpile-only mode: TypeScript (.ts, .tsx, .mts, .cts) and JSX (.jsx, .tsx). It explains the type-stripping behavior, the JSX factory and automatic runtime, the tsconfig.json fields that esbuild actually reads, and the isolatedModules constraint that follows from per-file compilation.

Key point: esbuild strips TypeScript types without checking them. A type error in the source is not a build error. The type-checking step must be run separately with tsc --noEmit, and the isolatedModules compiler option must be enabled because esbuild compiles each file independently.


Why esbuild transpiles without checking

The speed problem. Type-checking a TypeScript project requires the compiler to build a complete type graph across every file and every dependency. That is the expensive part of tsc. Stripping types from a single file is a local operation: parse the file, remove the type nodes, emit the JavaScript. esbuild is fast because it does the local operation and skips the global one. The esbuild documentation states this directly: “esbuild does not do type checking. You should handle type checking manually” .

The per-file problem. esbuild transforms one file at a time. A transform is a function from source text to output text. Type-checking is not a per-file operation; it requires knowledge of every module that the current file imports and every module that imports it. The TypeScript compiler is designed for this global analysis. esbuild is designed for the local transform. The two tools operate on the same source but produce different outputs: tsc produces diagnostics, esbuild produces JavaScript.

The isolatedModules consequence. Because esbuild compiles each file without tracing type references across files, some TypeScript constructs cannot be handled safely. A re-export of a type (export { SomeType } from './types') is ambiguous: esbuild cannot tell whether SomeType is a value or a type. The isolatedModules compiler option makes TypeScript report these constructs as errors, which forces the developer to write code that can be transpiled per-file. The esbuild documentation recommends enabling it, along with esModuleInterop for ESM compatibility .


a. TypeScript type stripping

The ts loader handles .ts, .mts, and .cts files. The tsx loader handles .tsx and .mtsx files. Both strip type-level syntax and convert the remaining runtime code to JavaScript.

The type-level constructs that are parsed and removed include interfaces, type aliases, function signatures, ambient declarations, and type-only imports and exports . The runtime constructs that are converted include namespaces, enums, generic type parameters, type casts, and experimental decorators (when experimentalDecorators is enabled in tsconfig.json) .

// input.ts
interface User {
  id: string
  name: string
}

export function greet(user: User): string {
  return `Hello, ${user.name}`
}
// output.js (approximately)
export function greet(user) {
  return `Hello, ${user.name}`
}

The User interface is gone. The parameter type annotation is gone. The return type annotation is gone. The function body is unchanged. If the function body accessed a property that does not exist on User, esbuild would not know. The build would succeed.

There are two notable limitations. const enum is not supported by esbuild because it requires cross-file type information to inline the enum values . emitDecoratorMetadata is not supported because it requires the TypeScript type system . For these features, the TypeScript compiler must be used instead of esbuild.


b. JSX transformation

The jsx loader handles .jsx files, and the tsx loader handles .tsx files. JSX syntax is converted into function calls. The default factory is React.createElement.

// input.jsx
import Button from './button'
let button = <Button>Click me</Button>
render(button)
// output.js
import Button from "./button";
let button = React.createElement(Button, null, "Click me");
render(button);

The React variable must be in scope. If the file does not import React, the output references an undefined variable. The automatic JSX runtime, enabled with --jsx=automatic or the "jsx": "react-jsx" field in tsconfig.json, generates the import statement automatically. The esbuild documentation recommends the automatic runtime for React 17+ projects .

For non-React libraries like Preact, the factory and fragment can be configured. The --jsx-factory=h and --jsx-fragment=Fragment options change the function names. The tsconfig.json fields jsxFactory and jsxFragmentFactory achieve the same result, and esbuild reads them .

JSX syntax is not enabled for .js files by default. To treat a .js file as JSX, the loader must be configured explicitly: loader: { '.js': 'jsx' } .


c. The tsconfig fields esbuild reads

esbuild does not read every tsconfig.json field. It reads a specific subset that affects the transform and the module resolution. The documentation lists the fields: experimentalDecorators, target, useDefineForClassFields, baseUrl, paths, jsx, jsxFactory, jsxFragmentFactory, jsxImportSource, alwaysStrict, strict, verbatimModuleSyntax, importsNotUsedAsValues, preserveValueImports, and extends .

The fields that are not in this list are ignored by esbuild. A noImplicitAny or strictNullChecks setting in tsconfig.json has no effect on the transform. Those fields are for the type-checker, not the transpiler.

The target field is read, but esbuild’s target option takes precedence if both are set. The jsx field controls the JSX runtime (react for the classic factory, react-jsx for the automatic runtime). The experimentalDecorators field must be enabled for decorator syntax to be transformed.


Complete Example Session

// ============================================
// PART 1: TYPE STRIPPING
// ============================================
// input.ts
interface User {
  id: string
  name: string
}

export function greet(user: User): string {
  return `Hello, ${user.name}`
}

// The interface and annotations are removed.
// The function body is unchanged.
// ============================================
// PART 2: A TYPE ERROR IS NOT A BUILD ERROR
// ============================================
// input.ts
export function add(a: number, b: number): number {
  return a + b
}

// app.ts
import { add } from './input'
const result = add("1", 2) // type error

// esbuild builds successfully.
// tsc reports the error.
// ============================================
// PART 3: CLASSIC JSX TRANSFORM
// ============================================
// input.jsx
import React from 'react'
const element = <div>Hello</div>

// output.js
import React from 'react'
const element = React.createElement("div", null, "Hello")
// ============================================
// PART 4: AUTOMATIC JSX RUNTIME
// ============================================
// input.jsx
const element = <div>Hello</div>

// output.js (with --jsx=automatic)
import { jsx as _jsx } from "react/jsx-runtime"
const element = _jsx("div", { children: "Hello" })

// No manual React import needed.
// ============================================
// PART 5: TSCONFIG JSX FIELD
// ============================================
// tsconfig.json
{
  "compilerOptions": {
    "jsx": "react-jsx"
  }
}

// esbuild reads this field and uses the automatic runtime.
// ============================================
// PART 6: NON-REACT FACTORY
// ============================================
// For Preact:
// --jsx-factory=h --jsx-fragment=Fragment

// tsconfig.json
{
  "compilerOptions": {
    "jsxFactory": "h",
    "jsxFragmentFactory": "Fragment"
  }
}
// ============================================
// PART 7: ISOLATEDMODULES
// ============================================
// tsconfig.json
{
  "compilerOptions": {
    "isolatedModules": true
  }
}

// Required because esbuild compiles each file independently.
// Catches type re-exports that need `export type`.
// ============================================
// PART 8: TYPE-ONLY RE-EXPORT
// ============================================
// Wrong (isolatedModules error)
export { User } from './types'

// Correct
export type { User } from './types'
// ============================================
// PART 9: THE TYPE-CHECK STEP
// ============================================
// package.json
{
  "scripts": {
    "build": "tsc --noEmit && esbuild src/index.ts --bundle --outfile=dist/index.js",
    "typecheck": "tsc --noEmit"
  }
}
// ============================================
// PART 10: THE LIMITATIONS
// ============================================
// const enum: not supported
// emitDecoratorMetadata: not supported
// .d.ts generation: not supported
//
// Use tsc for these.

The ten parts covered type stripping, the type-error-not-a-build-error behavior, the classic JSX transform, the automatic runtime, the tsconfig jsx field, the non-React factory, isolatedModules, the type-only re-export, the type-check step, and the limitations.


Quick Reference

Loaders

ExtensionLoaderBehavior
.ts, .mts, .ctstsStrip types, emit JS
.tsx, .mtsxtsxStrip types, transform JSX
.jsxjsxTransform JSX
.jsjsJSX disabled by default

TypeScript constructs

Constructesbuild support
InterfacesStripped
Type aliasesStripped
Type annotationsStripped
EnumsConverted
NamespacesConverted
DecoratorsWith experimentalDecorators
const enumNot supported
emitDecoratorMetadataNot supported
.d.ts generationNot supported

JSX options

OptionPurpose
--jsx=automaticAuto-import the JSX runtime
--jsx-factory=hCustom factory function
--jsx-fragment=FragmentCustom fragment
--loader:.js=jsxEnable JSX in .js files

tsconfig fields read by esbuild

FieldPurpose
experimentalDecoratorsEnable decorators
targetSyntax lowering target
jsxJSX runtime (react or react-jsx)
jsxFactoryCustom factory
jsxFragmentFactoryCustom fragment
isolatedModulesPer-file compilation constraints

Best Practices

✅ Do This:

// Enable isolatedModules
"isolatedModules": true                                      // ✅
// Use the automatic JSX runtime for React 17+
"jsx": "react-jsx"                                           // ✅
// Run tsc --noEmit separately
"build": "tsc --noEmit && esbuild ..."                       // ✅
// Use export type for type-only re-exports
export type { User } from './types'                          // ✅

❌ Don’t Do This:

// Assume esbuild catches type errors
const x: number = "string"  // builds fine                    // ❌
// Use JSX without React in scope and without automatic runtime
const x = <div />  // React is not defined                    // ❌
// Use const enum with esbuild
const enum Colors { Red }  // not supported                   // ❌
// Skip the type-check step in CI
"build": "esbuild ..."  // no tsc                             // ❌

Common Pitfalls

PitfallWhy It HappensFix
Type errors reach productionesbuild does not type-checkAdd tsc --noEmit to the build script
React is not definedClassic JSX transform without importUse --jsx=automatic or import React
isolatedModules error on re-exportType re-exported as valueUse export type
const enum failsesbuild cannot inline without type infoUse a regular enum or a const object
Decorators failexperimentalDecorators not enabledAdd to tsconfig.json
JSX in .js file not transformedJSX disabled by defaultConfigure the loader

Real-World Examples

1. Type stripping

interface User { id: string }
export function greet(user: User) { return user.id }

2. Classic JSX

import React from 'react'
const el = <div>Hello</div>

3. Automatic JSX

const el = <div>Hello</div> // with --jsx=automatic

4. Custom factory

esbuild.build({ jsxFactory: 'h', jsxFragment: 'Fragment' })

5. isolatedModules

{ "compilerOptions": { "isolatedModules": true } }

6. Type-only re-export

export type { User } from './types'

7. Type-check command

tsc --noEmit

8. Build script

{ "scripts": { "build": "tsc --noEmit && esbuild ..." } }

9. JSX in .js

loader: { '.js': 'jsx' }

10. Decorators

{ "compilerOptions": { "experimentalDecorators": true } }

Visual

The transpile boundary

┌─────────────────────────────────────────────────────────────┐
│  ESBUILD TRANSFORM                                           │
│                                                              │
│  source.ts                                                   │
│       │                                                      │
│       ▼                                                      │
│  parse                                                       │
│       │                                                      │
│       ├── strip interfaces                                   │
│       ├── strip type annotations                             │
│       ├── strip type aliases                                 │
│       └── convert enums/namespaces                           │
│       │                                                      │
│       ▼                                                      │
│  output.js                                                   │
│                                                              │
│  No type-checking at any step.                               │
└─────────────────────────────────────────────────────────────┘

JSX transform

┌─────────────────────────────────────────────────────────────┐
│  JSX TRANSFORM                                               │
│                                                              │
│  Classic (<div>Hello</div>):                                 │
│    React.createElement("div", null, "Hello")                 │
│                                                              │
│  Automatic (--jsx=automatic):                                │
│    import { jsx } from "react/jsx-runtime"                   │
│    jsx("div", { children: "Hello" })                         │
│                                                              │
│  Custom (--jsx-factory=h):                                   │
│    h("div", null, "Hello")                                   │
└─────────────────────────────────────────────────────────────┘

The two feedback loops

┌─────────────────────────────────────────────────────────────┐
│  DEVELOPMENT                        CI                        │
│                                                              │
│  IDE (tsc language service)         tsc --noEmit             │
│    ├── type errors as you type       ├── full program check  │
│    └── instant feedback               └── fails the pipeline │
│                                                              │
│  esbuild                             esbuild                  │
│    ├── transpiles on demand          ├── transpiles           │
│    └── no type-checking              └── produces the bundle │
└─────────────────────────────────────────────────────────────┘

tsconfig fields

┌─────────────────────────────────────────────────────────────┐
│  READ BY ESBUILD                IGNORED BY ESBUILD           │
│                                                              │
│  target                         strict                       │
│  jsx                            noImplicitAny                 │
│  jsxFactory                     strictNullChecks              │
│  isolatedModules                noUnusedLocals                │
│  experimentalDecorators         skipLibCheck                  │
│  paths                          (all type-checking fields)   │
│  baseUrl                                                      │
└─────────────────────────────────────────────────────────────┘

Summary

ItemValue
Type-checkingNot performed by esbuild
Type strippingPerformed by ts and tsx loaders
JSX transformPerformed by jsx and tsx loaders
Default JSX factoryReact.createElement
Automatic runtime--jsx=automatic or "jsx": "react-jsx"
isolatedModulesRequired for per-file compilation
const enumNot supported
emitDecoratorMetadataNot supported
Type-check commandtsc --noEmit

Key takeaways:

  • esbuild strips types without checking them. The ts and tsx loaders parse the file, remove the type-level syntax, and emit JavaScript. A type error is not a build error. The type-checking step must be run separately with tsc --noEmit .
  • isolatedModules is required for correctness. esbuild compiles each file independently, so TypeScript constructs that depend on cross-file type information must be written explicitly. export type and import type are the patterns that make the per-file model safe .
  • JSX is transformed into function calls. The default factory is React.createElement, which requires React to be in scope. The automatic runtime (--jsx=automatic or "jsx": "react-jsx") generates the import automatically and is the recommended setting for React 17+ .
  • esbuild reads a subset of tsconfig.json. The fields that affect the transform (target, jsx, experimentalDecorators, isolatedModules) are read. The fields that affect type-checking (strict, noImplicitAny) are ignored. The type-checker reads the full file .
  • Some TypeScript features are not supported. const enum and emitDecoratorMetadata require the TypeScript type system and cannot be handled by a per-file transpiler. Use regular enums and explicit metadata instead .
  • The build pipeline needs both tools. esbuild produces the bundle. tsc --noEmit produces the type diagnostics. The build script should run tsc --noEmit before or alongside esbuild to prevent type errors from reaching production.

Remember: esbuild’s TypeScript support is a transpiler, not a compiler. It converts the syntax and strips the types, and it does so fast enough to be the foundation of Vite’s development server. The trade-off is that the type system is not checked. The isolatedModules flag makes the per-file model safe by forcing the type/value distinction to be explicit. The tsc --noEmit step in CI provides the check that esbuild skips. Use both: esbuild for speed, tsc for correctness.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!