What Is a .tsx File?
A .tsx file is a TypeScript file that is allowed to contain JSX. That is the entire definition.
Same language, same compiler, same type checker — one extra piece of syntax switched on.
The reason the extension exists at all is a parsing conflict. TypeScript already gave <> a
meaning, and JSX wants the same characters for something else. Rather than guess, the compiler
looks at the file extension and decides which grammar to use. Everything else in this article
follows from that one decision.
What a .tsx file is
Take any React component. The types are ordinary TypeScript, the markup is JSX, and the two live in the same file:
interface GreetingProps {
name: string
excited?: boolean
}
export function Greeting({ name, excited = false }: GreetingProps) {
return <p>Hello {name}{excited ? '!' : '.'}</p>
}Rename that file to Greeting.ts and it stops compiling — not because React is missing, but
because <p> is not valid TypeScript outside a .tsx file. The compiler tries to read <p> as a
type assertion, runs out of road, and reports a syntax error.
JSX is also not React-specific. Preact, Solid, and Hono all use .tsx files. React is just the
most common thing on the other end of it.
.tsx vs .ts vs .jsx vs .js
Four extensions, two independent questions: does this file have types, and does it have markup?
| Extension | Types | JSX | Use it for |
|---|---|---|---|
.ts | ✅ | ❌ | logic, types, hooks, config, tests |
.tsx | ✅ | ✅ | components and anything that renders |
.js | ❌ | ❌ | plain JavaScript |
.jsx | ❌ | ✅ | JavaScript components, no type safety |
.jsx is the JavaScript sibling of .tsx. Same markup, no type checking. If you are migrating a
React codebase to TypeScript, .jsx → .tsx is the rename you will be doing a few hundred times.
Test files follow the same rule as everything else. A test that mounts a component and asserts on
the rendered output contains JSX, so it belongs in Button.test.tsx. A test for a pure helper
function does not, so format.test.ts is correct — and leaving it as .ts keeps the angle-bracket
syntax below working.
The practical rule is narrower than most people assume: use .tsx only when the file actually
contains JSX. A custom hook, a types.ts module, an API client, a Zod schema — none of those
render anything, so none of them need the .tsx grammar. Defaulting everything to .tsx costs you
nothing at runtime, but it does turn on the parsing quirks below in files that never needed them.
Nothing ever ships a .tsx file
No browser and no Node runtime understands a .tsx file. The extension is a build-time artefact:
something has to read it, throw the types away, turn the JSX into function calls, and write out
plain JavaScript. In a Next.js or Vite project that step is already wired up, which is why it feels
invisible until the day it breaks.
This matters for one practical reason — types disappear at runtime. Props are checked while you
edit and while you build, and then they are gone. If a .tsx file receives data from a network
request, the compiler believes whatever you told it, so validate at the boundary rather than
trusting the annotation.
The jsx setting in tsconfig
The extension tells the compiler how to parse the file. A separate tsconfig.json option tells it
what to emit:
{
"compilerOptions": {
"jsx": "react-jsx"
}
}The values worth knowing:
react-jsx— the modern default. Compiles JSX to calls imported fromreact/jsx-runtime, so you do not needimport React from 'react'at the top of every file. Available since TypeScript 4.1 and React 17.react-jsxdev— same thing with extra debug info. This is what your dev build should use.preserve— leaves the JSX untouched and emits.jsx. Pick this when a bundler like Vite, esbuild, or Babel does the JSX transform. Next.js and most modern setups land here or onreact-jsx.react— the classic transform, emittingReact.createElement. It requiresReactto be in scope in every file. Only relevant for older codebases.
If you target something other than React, point jsxImportSource at it:
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact"
}
}Get this wrong and the symptom is confusing: the file parses fine, then the compiler complains that
it cannot find react/jsx-runtime, or that your component "cannot be used as a JSX component". The
extension and the jsx option are two separate switches, and both have to be on.
Typing a component in a .tsx file
Props are just a parameter type, so an interface or a type alias does the whole job. Both work; interfaces merge and extend more comfortably, which is why most codebases default to them for props.
interface ButtonProps {
label: string
variant: 'primary' | 'ghost'
onClick: () => void
}
export function Button({ label, variant, onClick }: ButtonProps) {
return (
<button className={variant} onClick={onClick}>
{label}
</button>
)
}Two things to notice. variant is a union type, so
<Button variant="primry" /> is a compile error rather than a silently broken style. And onClick
is an ordinary function type — components are functions, and
their props are typed with the same tools as everything else.
You will still see React.FC<ButtonProps> in older code:
// ❌ The old habit — extra indirection, and it used to imply an implicit children prop
const Button: React.FC<ButtonProps> = ({ label }) => <button>{label}</button>
// ✅ A component is a function. Type the props, let TypeScript infer the return.
function Button({ label }: ButtonProps) {
return <button>{label}</button>
}Annotate the props, let the return type be inferred. That is the current recommendation from the React team, and it reads better besides.
For children, React.ReactNode is the type you want — it covers elements, strings, numbers, arrays,
null, and undefined:
interface CardProps {
title: string
children: React.ReactNode
}
export function Card({ title, children }: CardProps) {
return (
<section>
<h2>{title}</h2>
{children}
</section>
)
}Renaming a .jsx file to .tsx
Migrating a React codebase is mostly this rename, repeated. The order that hurts least:
- Turn on
allowJsso.jsand.jsxfiles keep working while you convert. - Rename one file, starting with a leaf component that nothing else depends on.
- Fix what the compiler reports. On a first rename that is almost always untyped props — every
parameter of a component is now an implicit
anyandstrictmode rejects it. - Add a props type, and let inference handle the rest.
// Before — Greeting.jsx, no types, every prop is whatever the caller passed
export function Greeting({ name }) {
return <p>Hello {name}</p>
}// After — Greeting.tsx, one interface and the call sites are checked
interface GreetingProps {
name: string
}
export function Greeting({ name }: GreetingProps) {
return <p>Hello {name}</p>
}Imports do not change. You import ./Greeting, not ./Greeting.tsx, so renaming a file does not
ripple through the files that use it. Resist converting a hundred files in one commit — each rename
turns previously invisible bugs into compiler errors, and you want to read them in small batches.
The one real gotcha: generic arrow functions
Here is the parsing conflict biting back. In a .ts file this is a perfectly ordinary
generic arrow function:
[object Object]Move it into a .tsx file and it breaks. The compiler sees <T> and starts parsing a JSX element,
then hits the end of the expression looking for a closing tag. The error message — JSX element 'T' has no corresponding closing tag — is at least honest about what happened.
Three fixes, all of them one character or one keyword:
// ✅ A trailing comma tells the parser this is a type parameter list
const identity = <T,>(value: T): T => value
// ✅ A constraint does the same job and reads more clearly
const identity2 = <T extends unknown>(value: T): T => value
// ✅ Or sidestep it entirely with a function declaration
function identity3<T>(value: T): T {
return value
}The trailing comma is the common one, and it looks like a typo forever. If a reviewer keeps deleting it, use the constraint form — same meaning, no mystery.
Angle-bracket type assertions do not work either
Same conflict, second victim. TypeScript's original assertion syntax is unusable in a .tsx file:
declare const input: unknown
// ✅ The `as` form works everywhere, which is why it is the one to standardise on
const value = input as stringThe <string>input form is a syntax error in any .tsx file, for exactly the reason <T> is. Use
as in every file and the question never comes up.
When a .tsx file will not compile
Two failures show up often enough to name.
The first is importing an untyped package. A JavaScript-only component library has no .d.ts files,
so TypeScript refuses the import and raises
TS7016: Could not find a declaration file for module. Install the matching
@types/* package, or write a one-line declaration for it.
The second happens while wiring up tsconfig.json in the first place: if outDir sits inside a
directory that include also picks up, the compiler finds its own emitted output on the next run
and reports
TS5055: Cannot write file, would overwrite input. Move outDir out of the source
tree, or exclude it.
Both are configuration problems rather than type problems, which is why they feel so unfair when they land on a file you have not finished writing yet.
Summary
A .tsx file is TypeScript plus JSX, and the extension exists only so the parser knows which
meaning of < you intended. Use it for files that render markup and plain .ts for everything
else. Set jsx in tsconfig.json to react-jsx unless a bundler owns the transform. And when
<T> suddenly stops working, remember which grammar you are in — add the comma and move on.
Become a TypeScript Pro
Track your progress through 100+ hands-on challenges. Free, sign in with GitHub.
Or start solving right away: explore all TypeScript challenges