Get eBook BundleVolume I index
Project Tooling and the First Milestone

TypeScript for Component Props

Ishtmeet Singh @ishtms/July 20, 2026/14 min read
#react#typescript#props#vite#type-checking

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:

jsx
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:

jsx
<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.

tsx
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:

tsx
<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.

So if you write:

tsx
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:

text
src/
  lesson-data.ts
  LessonCard.tsx
  App.tsx
  main.tsx

That 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:

tsx
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:

tsx
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.

bash
npm create vite@latest reactbook-typed -- --template react-ts

There'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:

bash
npm install --save-dev typescript @types/react @types/react-dom

Then 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.

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:

tsx
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:

js
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:

json
{
  "scripts": {
    "typecheck": "tsc -b --pretty",
    "build": "tsc -b && vite build"
  }
}

Then run:

bash
npm run typecheck

The 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.

Required props

By default, properties in a TypeScript object type are required.

tsx
type LessonCardProps = {
  title: string;
  minutes: number;
};

So this is invalid:

tsx
<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 ?.

tsx
type LessonCardProps = {
  title: string;
  minutes?: number;
};

Now minutes isn't just a number anymore. Inside the component its type is:

text
number | undefined

And if you blindly render it:

tsx
function LessonCard({ title, minutes }: LessonCardProps) {
  return <p>{title}, {minutes} min</p>;
}

you might end up showing:

text
Some lesson, undefined min

Beautiful.

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:

tsx
function LessonCard({ title, minutes = 10 }: LessonCardProps) {
  return <p>{title}, {minutes} min</p>;
}

Or maybe missing minutes means don't show the duration at all:

tsx
{minutes !== undefined && <span>{minutes} min</span>}

Both are fine depending on what the component is supposed to do.

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:

tsx
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:

tsx
type LessonStatus = "not-started" | "reading" | "complete";

type LessonCardProps = {
  title: string;
  status: LessonStatus;
};

Now this gets rejected:

tsx
<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:

tsx
const labels: Record<LessonStatus, string> = {
  "not-started": "Not started",
  reading: "Reading",
  complete: "Complete",
};

Now if we later add:

tsx
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.

tsx
type Lesson = {
  id: string;
  title: string;
  minutes: number;
  complete: boolean;
};

Now the card can receive a lesson object:

tsx
type LessonCardProps = {
  lesson: Lesson;
};

function LessonCard({ lesson }: LessonCardProps) {
  return <h2>{lesson.title}</h2>;
}

And a list can receive multiple lessons:

tsx
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:

tsx
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:

tsx
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.

tsx
<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:

tsx
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:

tsx
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.

tsx
import type { ReactNode } from "react";

type PanelProps = {
  title: string;
  children: ReactNode;
};

Then:

tsx
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:

tsx
type LessonCardProps = {
  lesson: Lesson;
};

Or with an interface:

tsx
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:

tsx
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:

tsx
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.

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:

bash
npm run typecheck

Fix 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?

bash
npm run typecheck

Because 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.