The short answer
Describe the props with a @typedef, put @param {CartItemProps} props on the component, and destructure as usual:
/**
* @typedef {object} CartItemProps
* @property {string} sku
* @property {string} name
* @property {number} cents
* @property {number} [quantity]
* @property {(sku: string) => void} onRemove
*/
/** @param {CartItemProps} props */
export function CartItem({ sku, name, cents, quantity = 1, onRemove }) {
return (
<li>
{name} × {quantity}: ${((cents * quantity) / 100).toFixed(2)}
<button type="button" onClick={() => onRemove(sku)}>
Remove
</button>
</li>
);
}export interface CartItemProps {
sku: string;
name: string;
cents: number;
quantity?: number;
onRemove: (sku: string) => void;
}
export function CartItem({ sku, name, cents, quantity = 1, onRemove }: CartItemProps) {
return (
<li>
{name} × {quantity}: ${((cents * quantity) / 100).toFixed(2)}
<button type="button" onClick={() => onRemove(sku)}>
Remove
</button>
</li>
);
}jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"lib": ["es2022", "dom", "dom.iterable"],
"jsx": "react-jsx",
"types": []
},
"include": ["*.jsx"]
} A component that uses it is checked against those props, and React’s hooks infer the rest: useState(initial) returns state of the same type as initial, and setLines only accepts that type:
import { useState } from "react";
import { CartItem } from "./CartItem.jsx";
/** @typedef {{ sku: string, name: string, cents: number, quantity: number }} Line */
/** @param {{ initial: Line[] }} props */
export function Cart({ initial }) {
const [lines, setLines] = useState(initial);
return (
<ul>
{lines.map((line) => (
<CartItem key={line.sku} {...line} onRemove={(sku) => setLines(lines.filter((l) => l.sku !== sku))} />
))}
</ul>
);
}jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"lib": ["es2022", "dom", "dom.iterable"],
"jsx": "react-jsx",
"types": []
},
"include": ["*.jsx"]
} Why it works
A function component is a function of one argument, its props, so a @param type on that argument is all tsc needs. With "jsx": "react-jsx" and @types/react installed, tsc checks each <CartItem … /> as a call with an object of attributes: required props must be present, and each value must match. Optional props ([quantity]) may be left out, and a default in the destructuring pattern fills them in.
The same thing in TypeScript
The TypeScript equivalent tab is CartItem.tsx, with interface CartItemProps and the annotation in the parameter list. The JSX is identical.
What goes wrong
A missing prop is TS2741, on the element that leaves it out; a prop of the wrong type is TS2322; and a state setter refuses a value of another type (TS2345):
import { useState } from "react";
import { CartItem } from "./CartItem.jsx";
export function Cart() {
const [count, setCount] = useState(0);
const lamp = <CartItem sku="LAMP-01" name="Desk lamp" cents={3900} />;error TS2741: Property 'onRemove' is missing in type '{ sku: string; name: string; cents: number; }' but required in type 'CartItemProps'.
const desk = <CartItem sku="DESK-02" name="Standing desk" cents="429.00" onRemove={() => setCount(count + 1)} />;error TS2322: Type 'string' is not assignable to type 'number'.
const reset = <button onClick={() => setCount("1")}>Reset</button>;error TS2345: Argument of type 'string' is not assignable to parameter of type 'SetStateAction<number>'.
return (
<ul>
{lamp}
{desk}
{reset}
</ul>
);
}jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"lib": ["es2022", "dom", "dom.iterable"],
"jsx": "react-jsx",
"types": []
},
"include": ["*.jsx"]
} Gotchas
childrenmust be declared. A component that renderschildrenneeds@property {import("react").ReactNode} [children]in its props type.- Empty initial state needs a type.
useState([])infersnever[]; writeuseState(/** @type {Line[]} */ ([]))so later updates are allowed. - Event handler props are plain function types.
onRemove: (sku: string) => voidchecks the handler’s arguments where the component calls it; for DOM events, use React’s types such asimport("react").ChangeEvent<HTMLInputElement>.
Questions
Can I use @type {import("react").FC<Props>} instead of @param?
Yes: /** @type {import("react").FC<CartItemProps>} */ on a const arrow function types its props the same way. @param on a function declaration is the simpler form and reads like any other function.
What does jsconfig.json need for JSX?
"jsx": "react-jsx" (the automatic runtime React 17 and later use) and .jsx files in include, plus @types/react installed. With a bundler such as Vite, keep its own JSX setting; the jsconfig.json one only tells tsc how to read the files.
Sources
- Using TypeScript (React docs) · React docs
- JSX (TypeScript handbook) · TypeScript handbook
- @types/react · npm
Last reviewed 21 October 2026.