TypeScript vs JavaScript for React UI: The Case for Strict Mode
TypeScript strict mode in React UI projects catches prop errors before they ship. Here's why typed components beat plain JS when you're building a real design system.
Honestly, Plain JavaScript Will Eventually Burn You
Honestly, JavaScript is fast to start with and genuinely fine for small projects. But once your React component count hits double digits and two people are touching the same codebase, you'll start hitting the wall. A prop renamed on Friday breaks production on Monday morning, and the error only shows up in a user's console — not yours.
TypeScript doesn't solve architecture problems. It solves the class of bugs that shouldn't exist: wrong prop types, undefined property access on an API response you didn't validate, a button variant that accepts 'primery' without blinking. Those bugs are embarrassing and TypeScript kills them dead at compile time.
This isn't about ideology. It's about the cost of debugging versus the cost of adding a type annotation. In most React UI projects, the math heavily favors TypeScript once the codebase matures past a prototype.
Empire UI, for example, ships every component with full TypeScript types. When you drop in a <GlassCard> component, your editor immediately tells you what props are available, which ones are optional, and exactly what values a variant prop accepts. That's not magic — it's just types doing their job.
What Strict Mode Actually Does to Your React Components
When you set "strict": true in your tsconfig.json, TypeScript enables a handful of checks that are individually named but collectively brutal on sloppy code. strictNullChecks is the biggest one — it forces you to acknowledge that a value could be null or undefined before you use it.
For React component props, strict mode means you can't accidentally pass a string where a number is expected and get a weird rendering artifact instead of an error. It means your onClick handler can't silently receive the wrong event type. It means optional props marked with ? are always treated as potentially absent, so you write the guard instead of assuming.
The TypeScript compiler itself doesn't slow your app down at runtime — it compiles away completely. What you're buying is a pre-flight check that runs every time you save a file. Think of it as a zero-cost test suite for your interface contracts.
Is strict mode annoying at first? Absolutely. You'll spend real time adding return type annotations and null guards that feel unnecessary. But after about two weeks on a real project, you start noticing you're writing fewer console.log debugging sessions. The annotations become muscle memory.
Typing React Props: A Real Before and After
Here's what a typed Empire UI-style button component looks like versus its plain JS counterpart. The difference is immediately visible when you try to use it wrong.
// JavaScript — no safety net
export function Button({ label, variant, size, onClick }) {
return (
<button
className={`btn btn-${variant} btn-${size}`}
onClick={onClick}
>
{label}
</button>
);
}
// TypeScript strict mode — editor catches mistakes instantly
type ButtonVariant = 'primary' | 'ghost' | 'outline' | 'danger';
type ButtonSize = 'sm' | 'md' | 'lg';
interface ButtonProps {
label: string;
variant?: ButtonVariant; // defaults handled in component
size?: ButtonSize;
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
disabled?: boolean;
}
export function Button({
label,
variant = 'primary',
size = 'md',
onClick,
disabled = false,
}: ButtonProps) {
return (
<button
className={`btn btn-${variant} btn-${size}`}
onClick={onClick}
disabled={disabled}
>
{label}
</button>
);
}The TypeScript version does more work upfront, yes. But now your editor autocompletes the variant prop, rejects variant="primery" with a red squiggle, and documents the component API without a single comment. Every teammate who uses this component gets that for free.
This pattern scales to glassmorphism cards, animated nav items, data tables — any component in your system. When you read about what glassmorphism is and how to implement it, you'll see why layered visual components with many configurable props benefit especially from typed interfaces. The surface area for mistakes is large.
JavaScript Still Wins in One Specific Scenario
Quick prototypes. Throwaway demos. Solo weekend projects you'll never deploy to production. In those cases, TypeScript's setup overhead isn't worth it — just grab Vite, pick React, and skip the tsconfig dance.
The problem is that most "prototypes" end up in production. That's the quiet lie of front-end development. You scaffold something fast in JavaScript, it works, the client loves it, and now you're maintaining a 3,000-line JS file with no type hints and a growing fear of touching anything.
If you know from day one that you're building something that will have more than one contributor and last more than three months, start with TypeScript. The migration path from JavaScript to TypeScript later is painful. It's not impossible — there are incremental migration strategies — but it's the kind of work that feels pointless because it's fixing problems that didn't have to exist.
The comparison between Tailwind UI and Empire UI touches on this too — pre-built component libraries that ship TypeScript types save you from writing those interface definitions yourself. That's a legitimate shortcut worth taking.
Setting Up TypeScript Strict Mode in a React + Tailwind Project
The default Vite TypeScript template is not strict by default. It sets up the bare minimum. You need to explicitly opt into strict mode in your tsconfig.json. Here's the config that actually matters for UI work:
// tsconfig.json — the settings that pull their weight
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true,
"exactOptionalPropertyTypes": true,
"skipLibCheck": true
}
}noUncheckedIndexedAccess is the underrated one here. If you do items[0], TypeScript now knows that could be undefined — because arrays don't guarantee a value at any index. This catches a whole class of runtime errors that strict mode alone misses.
With Tailwind v4.0.2 and its new CSS-first config, you'll be writing fewer configuration files overall. That makes TypeScript for your component layer even more valuable — it's the remaining source of structural guarantees in your project. The CSS handles the visual tokens, TypeScript handles the logic contracts.
Theme Tokens and Design System Typing
One area where TypeScript genuinely earns its keep in UI work is typed theme tokens. If your design system uses a token like color.surface.overlay with a value of rgba(255,255,255,0.15), you want exactly one source of truth — not a string you copy around and typo.
You can express this as a typed constant object and derive string literal types from it using as const and keyof typeof. Now any component that accepts a surface color will reject unknown strings at the type level, before any browser even opens.
This connects directly to how you implement features like theme toggle in React. When your light and dark theme tokens are typed, swapping them at runtime is just swapping which typed object you reference — no mismatches, no strings, no surprises at 16px or 32px scale alike.
The mental model shift is thinking of your design tokens as a typed API. The same rigor you apply to a REST response shape should apply to the values your components consume. TypeScript makes that consistency enforceable rather than aspirational.
Performance, Build Times, and the Real Overhead
TypeScript type checking happens at build time and in your editor — not at runtime. Your production bundle is plain JavaScript, identical in size to what you'd have shipped without TypeScript. There is no runtime performance penalty.
Build times do increase slightly. With a large project using tsc --noEmit for type checking plus a bundler like Vite that skips type checking during dev, the impact is minimal. Vite uses esbuild to strip types without checking them during development, so your hot module replacement stays fast. Only CI runs the full type check.
The real overhead is cognitive and upfront: learning TypeScript's type system well enough to write good interfaces. That's a legitimate cost. Budget about 20-40 hours for a developer new to TypeScript to reach comfortable productivity. After that, the daily DX improvement more than pays it back.
If you're evaluating your build tooling stack, the comparison of Vite vs Next.js and related tooling decisions apply here too. TypeScript integrates cleanly into both — the difference is whether you get type checking in the build step or rely on a separate tsc pass.
When to Add JSDoc Instead of Migrating to TypeScript
What if you have a large existing JavaScript codebase and can't migrate right now? JSDoc type annotations let you get TypeScript-style editor hints without converting files. You add // @ts-check at the top and write JSDoc comments, and most editors will run TypeScript's checker against those annotations.
// @ts-check
/**
* @param {{ label: string; variant?: 'primary' | 'ghost'; disabled?: boolean }} props
* @returns {JSX.Element}
*/
export function Button({ label, variant = 'primary', disabled = false }) {
return (
<button className={`btn btn-${variant}`} disabled={disabled}>
{label}
</button>
);
}It's more verbose than TypeScript interfaces and the tooling support isn't quite as tight. But it's a real option for incremental improvement without the full migration cost. You can also reference external .d.ts files from JavaScript using JSDoc, which means you can use typed libraries even from untyped source files.
The honest answer to 'TypeScript or JavaScript?' isn't 'always TypeScript.' It's 'TypeScript for anything that'll live longer than a sprint, JavaScript for true throwaway code.' Most React UI projects belong in the first category. Start typed, stay typed, and you'll wonder why you ever shipped components without prop interfaces.
FAQ
No. TypeScript types are erased at compile time. Your output is identical JavaScript — same bytes, same runtime performance. Strict mode only changes what the compiler accepts, not what it emits.
If you're migrating incrementally, enable TypeScript without strict first, convert files one by one, then tighten the config. Enabling strict mode on a large existing codebase all at once generates hundreds of errors. Gradual migration wins here.
strict: true is a shorthand that enables strictNullChecks, strictFunctionTypes, strictPropertyInitialization, noImplicitAny, noImplicitThis, and alwaysStrict all at once. It's the recommended starting point. You can then add additional flags like noUncheckedIndexedAccess on top.
Yes. Empire UI's TypeScript types are in .d.ts files alongside the compiled JS. You get the runtime behavior either way. But you'll lose editor autocomplete and prop validation if you're not in a TypeScript project — the types just won't be checked.
Even solo, TypeScript pays off on anything you return to after a few weeks. Without types, you'll be re-reading your own code to remember what a function expects. With types, the editor reminds you. The break-even point is maybe one week of project age.
For accepting arbitrary Tailwind class strings, use className?: string. If you want to restrict to specific classes, define a union type of the allowed strings. Libraries like tailwind-variants (now compatible with Tailwind v4) can help you build type-safe variant systems on top of Tailwind utility classes.