TypeScript for Component Props
Props are how one component talks to the parent that's rendering it. In plain JavaScript, that whole connection is based on you passing the correct prop names and the correct values. JavaScript itself isn't going to stop you if you mess that up.
Take this component:
function LessonCard({ title, minutes }) {
return <p>{title}, {minutes} min</p>;
}We as humans can look at it and say okay, title is probably supposed to be some text, and minutes looks like a number.
JavaScript doesn't know any of that.
You can very happily do this:
<LessonCard title={42} minutes="ten" />And React will receive exactly those values. Maybe the UI just looks stupid. Maybe nothing breaks immediately. Then later you add title.toUpperCase() somewhere and suddenly the app crashes in a completely different place from where you passed the wrong value.
TypeScript lets us write that expected prop contract right beside the component, and then your editor and type checker can complain much earlier.
What TypeScript actually checks
Rename the file to LessonCard.tsx, then describe what props this component accepts.
type LessonCardProps = {
title: string;
minutes: number;
};
function LessonCard({ title, minutes }: LessonCardProps) {
return <p>{title}, {minutes} min</p>;
}That : LessonCardProps after the destructured parameter tells TypeScript, "this object should have a title string and a minutes number".
Now if somebody writes:
<LessonCard title={42} minutes="ten" />you get errors right there at the component usage instead of finding out later in the browser.
Pretty useful.
But don't give TypeScript more credit than it deserves.
All of this checking happens before runtime. The types are removed from the JavaScript that eventually runs in the browser. So if your API sends some garbage JSON, TypeScript isn't sitting there at runtime inspecting it for you.
Same thing for localStorage, form input, URL data, random JSON files, or anything else coming from outside the typed code TypeScript already understands.
TypeScript checks relationships inside your code. Values entering the app at runtime still need runtime validation.
So if you write:
type User = {
id: string;
name: string;
};that doesn't somehow force your server to send a valid User.
It only tells TypeScript what your code expects a User to look like after you've got valid data.
Why .tsx exists
The file extensions are pretty simple.
Use .ts when the file contains TypeScript but no JSX. Use .tsx when the file contains JSX.
So you may end up with something like:
src/
lesson-data.ts
LessonCard.tsx
App.tsx
main.tsxThat x tells TypeScript and the build tooling that JSX syntax is allowed in this file.
If you rename App.jsx to App.ts while it still contains JSX, the parser is going to complain because .ts isn't supposed to contain JSX.
Also, don't start adding types to every single variable just because we're using TypeScript now.
This:
const title = "TypeScript for Component Props";
const minutes = 12;already gives TypeScript enough information to know that title is a string and minutes is a number.
Writing this:
const title: string = "TypeScript for Component Props";
const minutes: number = 12;doesn't really tell TypeScript anything new.
You'll usually get more value by typing the inputs coming into components, shared data structures, callback contracts, and places where TypeScript can't work the type out by itself. For obvious local values, just let inference do its job.
Starting with the Vite TypeScript template
If you're creating a brand new project, the easiest option is just start with Vite's React TypeScript template.
npm create vite@latest reactbook-typed -- --template react-tsThere's also a react-swc-ts variant if you want the SWC-based setup. For this project we'll stick with the regular react-ts template.
As of July 2026, Vite 8's official React TypeScript template uses TypeScript ~6.0.2, even though the native TypeScript 7 compiler is already stable. Don't immediately see the newer version and start manually changing package versions for no reason. TypeScript 7 still has compatibility differences around the programmatic compiler API that parts of the existing tooling rely on.
So for now, start with the version range Vite itself generated. If you later want to move the project to TypeScript 7, check the current TypeScript transition docs and test the tools your project actually uses.
For an existing JavaScript React project, install TypeScript plus the React type declaration packages first:
npm install --save-dev typescript @types/react @types/react-domThen add the TypeScript configuration.
And please don't copy some random tsconfig.json from a blog post written five years ago. Vite's generated config changes over time as TypeScript and bundlers change, so use the config from the current Vite template as your starting point.
react and @types/react do different jobs. react is runtime code used by the application. @types/react gives TypeScript and your editor the type information for React APIs.
Vite will transpile TypeScript without type-checking it
This part surprises quite a few people.
Vite can take .ts and .tsx files, remove the TypeScript syntax, transform the JSX, and run your app without doing a full TypeScript type check.
So this can exist in your project:
const total: number = "six";Obviously that's wrong. You declared total as a number and then gave it a string.
But Vite's normal transform step removes the type annotation and the browser effectively ends up with:
const total = "six";Perfectly valid JavaScript.
So your page can still load.
This is why "the Vite dev server runs fine" and "the TypeScript code is valid" are two different statements.
Add an actual type-check command:
{
"scripts": {
"typecheck": "tsc -b --pretty",
"build": "tsc -b && vite build"
}
}Then run:
npm run typecheckThe current Vite React TypeScript setup uses TypeScript project references, with separate configs for the browser application and Node-side tooling files. That's why we're using tsc -b here instead of just tsc.
A working dev server does not prove that TypeScript found zero errors. Run the type checker separately, and make sure your CI runs it too.
Required props
By default, properties in a TypeScript object type are required.
type LessonCardProps = {
title: string;
minutes: number;
};So this is invalid:
<LessonCard title="TypeScript for Props" />There's no minutes.
TypeScript reports that at the caller, which is exactly where you want to find it because that's where the incomplete LessonCard was created.
This also makes component inputs much easier to inspect. Open LessonCardProps and you can immediately see what the component expects without reading the whole function trying to figure out which props get used.
Optional props still need actual behavior
If a prop can genuinely be omitted, add ?.
type LessonCardProps = {
title: string;
minutes?: number;
};Now minutes isn't just a number anymore. Inside the component its type is:
number | undefinedAnd if you blindly render it:
function LessonCard({ title, minutes }: LessonCardProps) {
return <p>{title}, {minutes} min</p>;
}you might end up showing:
Some lesson, undefined minBeautiful.
The ? only says the prop may be missing. TypeScript doesn't invent sensible UI behavior for you.
If ten minutes is a reasonable default, write that:
function LessonCard({ title, minutes = 10 }: LessonCardProps) {
return <p>{title}, {minutes} min</p>;
}Or maybe missing minutes means don't show the duration at all:
{minutes !== undefined && <span>{minutes} min</span>}Both are fine depending on what the component is supposed to do.
Make a prop optional because the component works properly without it, not because adding ? made an annoying TypeScript error disappear.
When a string can only have a few allowed values
Suppose a lesson can have exactly three statuses.
You could type status as a plain string:
type LessonCardProps = {
title: string;
status: string;
};But then TypeScript allows literally any string, including "finished", "done", "compleet", "banana", whatever.
If the app only supports three values, say that:
type LessonStatus = "not-started" | "reading" | "complete";
type LessonCardProps = {
title: string;
status: LessonStatus;
};Now this gets rejected:
<LessonCard title="Props" status="completed" />because "completed" isn't one of the allowed values. We defined "complete".
At runtime these values are still normal strings. TypeScript isn't creating some new JavaScript object for them. But while writing code, your editor can autocomplete the allowed values and the compiler catches typos.
You can also connect that union to other typed data:
const labels: Record<LessonStatus, string> = {
"not-started": "Not started",
reading: "Reading",
complete: "Complete",
};Now if we later add:
type LessonStatus =
| "not-started"
| "reading"
| "complete"
| "archived";TypeScript complains about labels because there's no archived label yet.
That's nice because otherwise this is the kind of thing you add in one file and forget in another.
Give your actual data its own type
Once a lesson starts showing up in multiple places, define what one lesson contains.
type Lesson = {
id: string;
title: string;
minutes: number;
complete: boolean;
};Now the card can receive a lesson object:
type LessonCardProps = {
lesson: Lesson;
};
function LessonCard({ lesson }: LessonCardProps) {
return <h2>{lesson.title}</h2>;
}And a list can receive multiple lessons:
type LessonListProps = {
lessons: Lesson[];
};Lesson[] just means an array containing Lesson values.
One thing I'd avoid is using the exact same type for the lesson data and every component prop object just because they happen to contain similar fields today.
Lesson describes your lesson data.
LessonCardProps describes what LessonCard accepts.
Those can change separately.
Maybe later LessonCard also needs onOpen, compact, or showDuration. None of those necessarily belong on the Lesson data itself.
So keep those two ideas separate.
Callback props
Components also pass functions around through props all the time.
Let's say LessonCard needs to tell its parent that a lesson should open.
You can type that callback directly:
type LessonCardProps = {
lesson: Lesson;
onOpen: (lessonId: string) => void;
};That means onOpen is a function which accepts one string and doesn't return anything we care about.
Then:
function LessonCard({ lesson, onOpen }: LessonCardProps) {
return (
<button type="button" onClick={() => onOpen(lesson.id)}>
{lesson.title}
</button>
);
}Notice that the parent doesn't receive the browser click event.
The click event stays inside LessonCard. The parent receives the thing it actually needs: the lesson ID.
That keeps the component API about your application instead of making the parent know how LessonCard happened to implement the interaction in the DOM.
Let TypeScript infer DOM events when it can
For inline JSX handlers, TypeScript usually already knows the event type.
<input onChange={(event) => setQuery(event.target.value)} />You don't need to manually annotate event there. TypeScript sees that this is an onChange handler on an <input> and works out the event type from that context.
Hover over event in your editor and you'll see what it inferred.
If you move the handler into its own named function, then you normally add the type yourself:
import type { ChangeEvent } from "react";
function handleChange(event: ChangeEvent<HTMLInputElement>) {
setQuery(event.target.value);
}import type is useful here because we're only importing ChangeEvent for TypeScript. That import doesn't need to exist in the emitted runtime JavaScript.
And yeah, don't fix event type errors by doing this:
function handleChange(event: any) {
setQuery(event.target.value);
}Technically the error goes away.
But now TypeScript has stopped checking everything you do with event, which sort of defeats the reason we added TypeScript there in the first place.
Typing children
React doesn't automatically add children to every custom props type you create.
If your component accepts children, put it in the type.
import type { ReactNode } from "react";
type PanelProps = {
title: string;
children: ReactNode;
};Then:
function Panel({ title, children }: PanelProps) {
return (
<section>
<h2>{title}</h2>
{children}
</section>
);
}ReactNode covers the normal things React accepts as children: React elements, strings, numbers, arrays of nodes, empty values, and so on.
Don't make the child type super narrow just because your first usage happened to pass one <p>.
Only restrict it if the component actually requires a certain kind of child.
type or interface?
You can write props with a type alias:
type LessonCardProps = {
lesson: Lesson;
};Or with an interface:
interface LessonCardProps {
lesson: Lesson;
}Both work for this.
In this project I'm using type consistently because we're also using aliases for unions and it's simpler to stick with one convention.
You will absolutely find codebases that prefer interface, and that's also fine.
This is one of those topics developers can somehow turn into a thirty-minute argument even when either version would have compiled five seconds after we started.
Pick a convention that fits your codebase and move on.
Be careful with as
Type assertions are very tempting because they can make an error disappear immediately.
Suppose you do this:
const lesson = lessons.find((item) => item.id === selectedId) as Lesson;find() can return undefined.
TypeScript knows that.
By writing as Lesson, you're telling TypeScript, "don't worry, I know this is definitely a Lesson".
But nothing changed at runtime.
If no lesson matched, the value is still undefined. You've only told the type checker to stop complaining about it.
A better version is to handle the case that can actually happen:
const lesson = lessons.find((item) => item.id === selectedId);
if (!lesson) {
return <p>Select an available lesson.</p>;
}After that check, TypeScript knows lesson exists, so the rest of the code can use it normally.
as can hide a useful error very quickly. Use assertions when you genuinely know something TypeScript cannot determine, not because dealing with undefined was inconvenient.
Moving an existing project to TypeScript
I wouldn't rename the entire project from .jsx to .tsx in one shot unless the app is tiny.
Do one small component first.
Install TypeScript and the React type packages, add the current Vite TypeScript config, then rename something small such as LessonCard.jsx to LessonCard.tsx.
Add the Lesson type and LessonCardProps, then run:
npm run typecheckFix the callers TypeScript complains about.
Once LessonCard is clean, move upward to LessonList, then App, then main.
JavaScript and TypeScript files can live together during a migration if your configuration allows it, so there's usually no need to turn the entire codebase into one giant error screen at once.
Moving one component at a time also makes the errors easier to understand because you know which change probably caused them.
Before calling the component typed, check the contract
Once you've typed a component, read the prop type once from the caller's point of view.
If a prop is required, does the component genuinely fail to make sense without it?
If it's optional, what does the component actually do when it's missing?
If you're passing a callback, is the callback talking in terms your app understands, or are you unnecessarily passing raw browser events upward?
If data came from a server, storage, a form, or some other runtime source, did you validate it before treating it as trusted typed data?
And finally, does this command pass?
npm run typecheckBecause again, Vite successfully opening the page doesn't answer that question.
TypeScript becomes much more useful when the types describe what the code really expects. You can add types to every line in the project, but if those types are lying, you've just made the wrong assumptions look more official.