React with TypeScript: The Four Type Constructs Doing All the Work
139 typed files, 92 components, zero React.FC and zero any. The props pattern, the exhaustive union and the Record lookup that carry a production React app.
Most guides to using React with TypeScript stop at React.FC and a props interface. This storefront is 139 .tsx files, 81 .ts files and 92 components, and it uses React.FC exactly zero times. Here is what it uses instead, and the four type constructs doing almost all of the real work.
The short answer
Add TypeScript to React by scaffolding with it (npx create-next-app@latest --typescript) or installing typescript @types/react @types/react-dom into an existing project. Then type your props as a plain inline object on the function's parameter, turn on strict, and let JSX inference do the rest. No React.FC, no manual return types.
Setting it up, and the two tsconfig lines that matter
Every modern scaffold gives you a working tsconfig.json. Only two of its options change how React code is written day to day. This is ours, unedited:
{
"compilerOptions": {
"strict": true,
"jsx": "react-jsx",
"isolatedModules": true,
"moduleResolution": "bundler",
"noEmit": true,
"paths": { "@/*": ["./src/*"] }
}
}
"jsx": "react-jsx" is the automatic runtime. It is why import React from "react" appears in 0 of 220 source files here — the compiler injects the JSX factory itself. If you are reading a tutorial that starts every component file with that import, it predates this setting.
"strict": true is the whole point. Without it, TypeScript in React degrades to autocomplete: null flows silently into props, optional fields read as present, and the type errors you wanted are the exact ones suppressed. Turning it off to make a build pass converts a compile-time failure into a runtime one — which is the opposite trade to the one you adopted TypeScript for.
isolatedModules is worth one sentence because it produces a confusing error: every type-only import must say so. import { ReactNode } from "react" fails; import type { ReactNode } from "react" is correct, and that is the form used throughout this codebase.
Typing a component: the pattern used 92 times here
There are two ways to type a function component, and the ecosystem has quietly settled on one of them:
React.FC<Props> | Inline props object | |
|---|---|---|
| Children | Implicit in older @types/react, removed in React 18+ types | Declared explicitly when you want them |
| Generic components | Awkward — the generic has to wrap the annotation | Natural — function List<T>({ items }: { items: T[] }) |
Default props / defaultProps | Typed against a legacy API React has deprecated | Ordinary JS default parameters |
| Return type | Fixed by the alias | Inferred, and narrower |
| Used in this repo | 0 times | 92 times |
The inline form is a plain function whose single parameter happens to be an object type. BlogCtaLink, the client leaf that fires our blog conversion event, is the shape in full:
export default function BlogCtaLink({
href,
postSlug,
targetSlug,
className,
children,
}: {
href: string;
postSlug: string;
targetSlug: string;
className?: string;
children: ReactNode;
}) {
return (
<Link href={href} className={className} onClick={() => track("blog_cta_clicked", { post: postSlug, target: targetSlug })}>
{children}
</Link>
);
}
Three things in that snippet are the actual house style, and none of them need a library:
children: ReactNodeis declared, not inherited. Since React 18's types dropped implicit children fromReact.FC, "does this component take children" became a decision you state rather than one you get for free. Stating it is better: a component that does not renderchildrenshould reject them.className?: stringis how every atom here accepts style overrides. The?is load-bearing understrict— without it every call site must pass one.- No return annotation. TypeScript infers the JSX element type more precisely than you would write it.
Where props are shared across components, hoist the object into an exported interface — this repo exports 67 of them — and keep the inline form for one-offs.
Where TypeScript actually earns its keep: discriminated unions
Props typing is the part everyone shows. The part that pays for the toolchain is modelling domain rules so that the compiler checks them.
Our licence engine (src/lib/entitlements.ts) decides whether a buyer may download a given file. A slot is one of three kinds, and each kind grants a different scope:
export type SlotKind = "template_single" | "all_templates" | "all_access";
export interface Slot {
kind: SlotKind;
productSlug: string | null;
framework: string | null;
status: "active" | "revoked";
}
export function slotCovers(slot: Slot, target: DownloadTarget): boolean {
if (slot.status !== "active") return false;
switch (slot.kind) {
case "all_access":
return true;
case "all_templates":
return target.framework !== "backend";
case "template_single":
return (
slot.productSlug === target.productSlug &&
target.framework !== "backend"
);
}
}
That switch has no default branch, and that is deliberate. Because SlotKind is a union of three string literals and the function declares : boolean, TypeScript verifies that every member is handled — the moment a fourth tier is added to the product, this function stops compiling until someone decides what it grants. A default: return false would have silently denied downloads to a paying customer instead. Exhaustiveness is the single most valuable thing React with TypeScript gives you, and it has nothing to do with components.
The same shape covers UI vocabulary. Our editorial clusters are a union, and the label map is keyed by it:
export type BlogCluster = "tutorial" | "comparison" | "guide" | "roundup";
export const CLUSTER_LABELS: Record<BlogCluster, string> = {
tutorial: "Tutorial",
comparison: "Comparison",
guide: "Guide",
roundup: "Roundup",
};
Record<BlogCluster, string> means adding a fifth cluster breaks the build at the map, not at the one page that renders an empty badge three weeks later.
Record and function types, or how a typo becomes a build error
src/lib/blog.ts pairs 238 published articles with their compiled MDX modules. The map is written out by hand, slug by slug, and its type is the reason:
const postLoaders: Record<string, () => Promise<{ default: ComponentType }>> = {
"react-install": () => import("@/content/blog/react-install.mdx"),
"nextjs-search-params": () => import("@/content/blog/nextjs-search-params.mdx"),
// ...one line per post
};
A dynamic import built from a template literal would be shorter and would type as any. With explicit entries, a misspelled filename fails next build, and ComponentType guarantees the default export is something React can render. The rule generalises: when a value is looked up by string, spend the extra lines to give the lookup a type. That is where TypeScript in a React app converts runtime blanks into build failures.
What strict catches inside JSX
JSX is type-checked as expressions, so errors surface in places that look like markup. The clearest example is one this codebase hit for real. SVG icons are embedded directly in src/data/*.tsx — 12 of the 15 files there are .tsx rather than .ts purely because their items carry JSX — and SVG element and attribute names must be written in JSX casing. Dropping to the HTML lowercase spelling still runs in dev and fails the build:
src/probe.tsx(4,7): error TS2339: Property 'clippath' does not exist on
type 'JSX.IntrinsicElements'.
That is npx tsc --noEmit on this repo, run against a deliberately broken <clippath> element. The fix is <clipPath>, and the same rule governs strokeWidth, fillRule, viewBox and linearGradient. The dev server is forgiving here and next build is not, which is the general shape of TypeScript-in-JSX failures: they arrive at build time, in bulk, from code that appeared to work.
Mistakes and how they show up
| Symptom | Cause | Fix |
|---|---|---|
Property 'children' does not exist on type | React 18+ types removed implicit children from React.FC | Declare children: ReactNode in the props type |
Re-exporting a type when 'isolatedModules' is enabled | A type imported with a value import | import type { … } |
Property 'clippath' does not exist on type 'JSX.IntrinsicElements' | HTML-cased SVG name in JSX | Use JSX casing (clipPath, strokeWidth, fillRule) |
Props type is any, no errors anywhere | Untyped third-party module, or strict off | Install the @types/* package; re-enable strict |
Object is possibly 'null' on a ref | useRef<HTMLDivElement>(null) is nullable by design | Narrow with if (!ref.current) return before use |
Event handler arg implicitly any | Handler extracted out of JSX, losing inference | Annotate: (e: React.MouseEvent<HTMLButtonElement>) |
| Build fails only in CI, passes locally | The dev server does not type-check | Run npx tsc --noEmit — or npm run build — before pushing |
Frequently asked questions
Do I need to use React.FC to write a React component in TypeScript?
No. Type the props as an object on the function parameter and let the return type be inferred. React.FC adds nothing once implicit children are gone, and it makes generic components harder to write. This codebase has 92 components and zero uses of it.
How do I add TypeScript to an existing React project?
Install typescript @types/react @types/react-dom, add a tsconfig.json with "jsx": "react-jsx" and "strict": true, then rename files to .tsx one at a time. allowJs: true lets .js and .tsx coexist while you migrate.
Is .ts or .tsx the right extension?
.tsx only if the file contains JSX. That is a real distinction, not cosmetic: our src/data folder holds 12 .tsx files whose items embed icon markup and 3 .ts files that hold plain data. Keeping non-JSX modules as .ts keeps the JSX parser out of them, which matters for generic arrow functions.
Does TypeScript slow the build down?
It does not run in the browser and adds nothing to the bundle. It costs a type-check pass: npm run build here type-checks every component, and the 347-assertion test suite across 31 files runs in about 3 seconds on top of it. The cost is CI time, not user-facing performance.
Where to take this next
The patterns above are worth the most where the data model is large. How we structure components with Atomic Design shows the layering these types are attached to, React Server Components vs. Client Components covers which of those 92 components actually ship their types to the browser, and React install has the dependency-weight numbers behind the file counts here.
Templates in this post
ASoc Ignite, ASoc Iris and ASoc Keystone ship in TypeScript with strict already on, props typed in the inline form above, and a build that type-checks clean out of the box.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
