esbuild 08 ⚡ TypeScript & JSX — Transpilation without type-checking
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
| Extension | Loader | Behavior |
|---|---|---|
.ts, .mts, .cts | ts | Strip types, emit JS |
.tsx, .mtsx | tsx | Strip types, transform JSX |
.jsx | jsx | Transform JSX |
.js | js | JSX disabled by default |
TypeScript constructs
| Construct | esbuild support |
|---|---|
| Interfaces | Stripped |
| Type aliases | Stripped |
| Type annotations | Stripped |
| Enums | Converted |
| Namespaces | Converted |
| Decorators | With experimentalDecorators |
const enum | Not supported |
emitDecoratorMetadata | Not supported |
.d.ts generation | Not supported |
JSX options
| Option | Purpose |
|---|---|
--jsx=automatic | Auto-import the JSX runtime |
--jsx-factory=h | Custom factory function |
--jsx-fragment=Fragment | Custom fragment |
--loader:.js=jsx | Enable JSX in .js files |
tsconfig fields read by esbuild
| Field | Purpose |
|---|---|
experimentalDecorators | Enable decorators |
target | Syntax lowering target |
jsx | JSX runtime (react or react-jsx) |
jsxFactory | Custom factory |
jsxFragmentFactory | Custom fragment |
isolatedModules | Per-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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Type errors reach production | esbuild does not type-check | Add tsc --noEmit to the build script |
React is not defined | Classic JSX transform without import | Use --jsx=automatic or import React |
isolatedModules error on re-export | Type re-exported as value | Use export type |
const enum fails | esbuild cannot inline without type info | Use a regular enum or a const object |
| Decorators fail | experimentalDecorators not enabled | Add to tsconfig.json |
JSX in .js file not transformed | JSX disabled by default | Configure 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
| Item | Value |
|---|---|
| Type-checking | Not performed by esbuild |
| Type stripping | Performed by ts and tsx loaders |
| JSX transform | Performed by jsx and tsx loaders |
| Default JSX factory | React.createElement |
| Automatic runtime | --jsx=automatic or "jsx": "react-jsx" |
isolatedModules | Required for per-file compilation |
const enum | Not supported |
emitDecoratorMetadata | Not supported |
| Type-check command | tsc --noEmit |
Key takeaways:
- esbuild strips types without checking them. The
tsandtsxloaders 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 withtsc --noEmit. isolatedModulesis required for correctness. esbuild compiles each file independently, so TypeScript constructs that depend on cross-file type information must be written explicitly.export typeandimport typeare 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=automaticor"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 enumandemitDecoratorMetadatarequire 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 --noEmitproduces the type diagnostics. The build script should runtsc --noEmitbefore 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.
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!