Skip to main content
ASoc
Tutorial

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.

The ASoc Team9 min read

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
ChildrenImplicit in older @types/react, removed in React 18+ typesDeclared explicitly when you want them
Generic componentsAwkward — the generic has to wrap the annotationNatural — function List<T>({ items }: { items: T[] })
Default props / defaultPropsTyped against a legacy API React has deprecatedOrdinary JS default parameters
Return typeFixed by the aliasInferred, and narrower
Used in this repo0 times92 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: ReactNode is declared, not inherited. Since React 18's types dropped implicit children from React.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 render children should reject them.
  • className?: string is how every atom here accepts style overrides. The ? is load-bearing under strict — 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

SymptomCauseFix
Property 'children' does not exist on typeReact 18+ types removed implicit children from React.FCDeclare children: ReactNode in the props type
Re-exporting a type when 'isolatedModules' is enabledA type imported with a value importimport type { … }
Property 'clippath' does not exist on type 'JSX.IntrinsicElements'HTML-cased SVG name in JSXUse JSX casing (clipPath, strokeWidth, fillRule)
Props type is any, no errors anywhereUntyped third-party module, or strict offInstall the @types/* package; re-enable strict
Object is possibly 'null' on a refuseRef<HTMLDivElement>(null) is nullable by designNarrow with if (!ref.current) return before use
Event handler arg implicitly anyHandler extracted out of JSX, losing inferenceAnnotate: (e: React.MouseEvent<HTMLButtonElement>)
Build fails only in CI, passes locallyThe dev server does not type-checkRun 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.

Keep reading

Tutorial9 min read

Robots.txt and Sitemap.xml in Next.js: One Declared Date, 420 Routes

The STOREFRONT_COPY_REVISED fix for a sitemap that told Google 111 pages changed on a date nothing did, plus the noindex-vs-disallow split this codebase actually uses.

Read more