TypeScript: Get the Props of a Component (and When Not To)
ComponentProps<typeof X> in one line, then the harder question: 92 components here use it zero times. The two patterns that remove the need, and the one that doesn't.
To get the props of a component in TypeScript, use ComponentProps<typeof Component>. It reads the prop type back off the component itself, so it stays correct when that component's props change and it works even when the author never exported a props interface. For a plain DOM element, ComponentProps<"button"> does the same job.
That is the whole API. The harder question — the one worth 1,500 words — is when you should reach for it, because a codebase that uses it everywhere has usually hidden a design problem. This storefront has 92 components across 220 TypeScript files and uses ComponentProps zero times, and the reasons it doesn't are the same reasons you might need to.
The three ways a component's prop type can be reachable
| Situation | How to get the props | Stays in sync? |
|---|---|---|
Author exported the interface (export interface ButtonProps) | Import it | Yes |
| Props are an inline or unexported type | ComponentProps<typeof Button> | Yes |
| Props type is a named type you already own (a data row) | Import that type | Yes |
| You retyped the props by hand to match | Nothing keeps them aligned | No |
The fourth row is the one ComponentProps exists to delete. Hand-copying a prop type compiles on the day you write it and drifts silently the day the component gains a required prop.
The utility, and the three forms of it
import type { ComponentProps, ComponentPropsWithoutRef, ComponentRef } from "react";
// A component you wrote or imported
type ButtonProps = ComponentProps<typeof Button>;
// An intrinsic element — note the string, not a type
type NativeButtonProps = ComponentProps<"button">;
// Same, minus `ref` — the right default when you spread onto your own markup
type Clean = ComponentPropsWithoutRef<"input">;
// The element type a ref would point at
type Target = ComponentRef<"input">; // HTMLInputElement
typeof is doing real work in the first line and is the step people miss. Button is a value — a function. ComponentProps needs the type of that value, and typeof Button is how you ask for it in type position. Writing ComponentProps<Button> is an error unless Button happens to also be a type name.
ElementRef is the older spelling of ComponentRef and still works; in the @types/react 19 copy this repo builds against it carries @deprecated Use ComponentRef<T> instead, so ComponentRef is the one to write in new code.
Why this repo has 92 components and zero uses of it
Two patterns in this codebase remove the need, and both are worth copying before you reach for the utility.
First: when props are the data, the props type is already a named export. src/components/molecules/FeatureCard.tsx in full:
import type { FeatureItem } from "@/data/features";
/** Molecule: a bordered feature card with icon, title and description. */
export default function FeatureCard({ icon, title, description }: FeatureItem) {
There is nothing to extract. The props type is FeatureItem, exported from the data file the organism maps over, so a component, its content and anything wrapping it all type-check against one declaration. 44 import type lines across src/components are this pattern — the architecture rule behind it is that molecules may import types from data files but never runtime values, so the type is the only thing crossing the boundary.
Second: a component nothing wraps does not need a nameable prop type. src/components/atoms/SectionHeading.tsx declares its props inline and exports only the component:
export default function SectionHeading({
as: Tag = "h2",
className = "...",
children,
}: {
as?: "h1" | "h2";
className?: string;
children: ReactNode;
}) {
Nothing in the tree builds a different heading on top of this one, so an exported SectionHeadingProps would be API surface with no consumer. The inline type is the honest shape.
That is the real heuristic: ComponentProps is for components you did not write, or did not write for this. A design-system button from npm, a third-party chart, your own atom on the day something finally wraps it.
The case where hand-copying actually breaks
src/components/atoms/Button.tsx is the component in this repo where you would need the utility, and it shows why. Its props are an inline type intersected with the full DOM attribute set:
export type ButtonVariant = "primary" | "outline" | "dark";
export default function Button({
variant = "primary",
href = "#",
external = false,
disabled = false,
className = "",
children,
...rest
}: {
variant?: ButtonVariant;
href?: string;
external?: boolean;
disabled?: boolean;
className?: string;
children: ReactNode;
} & React.AnchorHTMLAttributes<HTMLAnchorElement>) {
ButtonVariant is exported; the props type is not, and it is not a type you can reasonably retype. Counted in the @types/react 19 declarations this repo builds against, the right-hand side of that intersection declares 286 members: 8 on AnchorHTMLAttributes itself, 55 inherited from HTMLAttributes, 53 aria-* attributes and 170 DOM event handlers. So a wrapper extracts it:
import type { ComponentProps } from "react";
import Button from "@/components/atoms/Button";
type ButtonProps = ComponentProps<typeof Button>;
/** A CTA that reports its own click, with Button's entire API intact. */
export default function TrackedButton({ onClick, ...rest }: ButtonProps) {
return <Button {...rest} onClick={(e) => { track(rest.href); onClick?.(e); }} />;
}
Checked against this repo's tsconfig while writing this post. { variant: "outline", href: "/pricing", target: "_blank", children: "x" } type-checks — variant resolved through the inline half, target through AnchorHTMLAttributes — and variant: "ghost" produced the one error tsc --noEmit reported:
error TS2322: Type '"ghost"' is not assignable to type 'ButtonVariant | undefined'.
Both halves of the intersection survive the extraction. That is the property you are buying.
Picking a prop out, and omitting one
Extraction composes with the normal type operators, which is where most of its practical value is.
type ButtonProps = ComponentProps<typeof Button>;
// One prop's type, for a variable or a function parameter
type Variant = ButtonProps["variant"]; // ButtonVariant | undefined
type Variant2 = NonNullable<ButtonProps["variant"]>; // ButtonVariant
// Take over a prop in a wrapper
type Props = Omit<ButtonProps, "href"> & { slug: string };
// Require something the original made optional
type Strict = ButtonProps & Required<Pick<ButtonProps, "variant">>;
Omit before intersecting is the important one. Adding { href: string } to a type that already has href?: string gives you an intersection of both, not a replacement, and the error it eventually produces points at the call site rather than at the wrapper.
Mistakes and how they show up
| Symptom | Cause | Fix |
|---|---|---|
'Button' refers to a value, but is being used as a type | ComponentProps<Button> | ComponentProps<typeof Button> |
Props resolve to {} or any | The component is untyped, or typed as a plain Function | Nothing to extract; declare the props yourself |
ref shows up in a spread and breaks the DOM | ComponentProps includes ref | ComponentPropsWithoutRef |
| Generic component loses its type parameter | Extraction instantiates generics at their constraint | Keep the generic: <T,>(p: MyProps<T>), don't extract |
| Your new required prop is accepted as optional | You intersected instead of omitting | Omit<P, "x"> & { x: string } |
ComponentProps<"button"> errors | A type was passed where a tag name belongs | Use the string literal "button" |
Extracted props type is correct but unusable in a .ts file | typeof needs the component imported as a value | import Button from ..., not import type |
The last row has a wrinkle worth stating: import type Button from "..." is enough for ComponentProps<typeof Button> in a type-only file, and that is what the verification above used. You need the value import only when the file also renders the component.
Frequently asked questions
What is the difference between ComponentProps and ComponentPropsWithoutRef?
Only ref. ComponentProps gives you the props as the component accepts them, ref included where it has one; ComponentPropsWithoutRef strips it. Spreading props that still contain ref onto your own element is the usual way this bites, so prefer the WithoutRef form in a wrapper unless you are deliberately forwarding.
How do I get the props of an HTML element rather than a component?
Pass the tag as a string: ComponentProps<"input">, ComponentProps<"a">. This is the type you want when writing an atom that wraps a native element and should accept everything that element accepts — the React.AnchorHTMLAttributes<HTMLAnchorElement> in this repo's Button is the longhand of the same idea.
Should every component export a props interface?
No. Export it when something else is meant to build on the component — a design-system primitive, anything published as a package. For a leaf component inside one app, an inline type is fine and ComponentProps covers the day that changes. 92 components here, and the ones that export a props type are the ones with a second consumer.
Does this work with forwardRef components?
Yes. ComponentProps<typeof MyForwardRefComponent> resolves to the props plus ref, which is usually what you want for a wrapper that passes a ref through. Use ComponentRef<typeof MyForwardRefComponent> when you need the type of the element the ref points at.
Landing pages with the prop types already drawn
If you are deriving prop types because a page needs building rather than because types are the task, ASoc Remit, ASoc Script and ASoc Seeker are landing sets built the way this post describes — typed data rows, components that take them, and no any to work around.
Browse the sets: Next.js landing page templates and Tailwind landing page templates.
