As we saw in the previous section, the tsconfig.json file is the command center for TypeScript in your project. It tells TypeScript:
- How to compile your code—or in React’s case—how to type-check it.
- Which files to include/exclude.
- How strict to be about type checking.
- How to resolve modules and paths.
In a React + Vite project, TypeScript doesn’t actually compile your code—Vite does that. Instead, TypeScript purely provides type checking. This is why we use noEmit: true.
The Complete Configuration
Let’s build the optimal tsconfig.json step by step:
{
"compilerOptions": {
// Target and Library Settings
"target": "ES2022",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
// JavaScript Support
"allowJs": true,
"checkJs": false,
// Emit Configuration
"noEmit": true,
"sourceMap": true,
"jsx": "react-jsx",
// Type Checking - Strict Settings
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": false,
"exactOptionalPropertyTypes": false,
"noFallthroughCasesInSwitch": true,
// Type Checking - Code Quality
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitReturns": true,
"allowUnreachableCode": false,
"allowUnusedLabels": false,
// Module Resolution
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"verbatimModuleSyntax": true,
// Path Mapping
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@hooks/*": ["src/hooks/*"],
"@utils/*": ["src/utils/*"]
},
// Performance
"skipLibCheck": true,
"incremental": true,
"tsBuildInfoFile": "./node_modules/.cache/typescript/tsconfig.tsbuildinfo",
// Consistency
"forceConsistentCasingInFileNames": true,
// Environment Types
"types": ["vite/client"]
},
"include": ["src/**/*", "vite.config.ts"],
"exclude": ["node_modules", "dist", "build", "coverage"]
}Target and Library Settings
"target": "ES2022"
What it does: Specifies the JavaScript version TypeScript compiles to.
Why ES2022?
- Includes modern features like top-level await, class fields, and
.at()method - All modern browsers support ES2022
- Vite will handle any additional transpilation needed for older browsers
Alternatives:
"ESNext": Always latest features (less predictable)"ES2020": More conservative, but misses nice features
"lib": ["DOM", "DOM.Iterable", "ES2022"]
What it does: Tells TypeScript which built-in APIs are available.
Breaking it down:
"DOM": Browser APIs (document, window, HTMLElement)"DOM.Iterable": Makes DOM collections iterable (for…of loops on NodeLists)"ES2022": JavaScript language features matching our target
Could add: "WebWorker" if using web workers, "ES2022.Array" for specific features
"module": "ESNext"
What it does: Specifies the module system for emitted code.
Why ESNext? We’re using native ES modules, and Vite handles the actual module transformation. This gives us:
- Dynamic imports
- Top-level await
- Import assertions
"moduleResolution": "bundler"
What it does: How TypeScript resolves import statements.
Why bundler? New in TS 5.0+, designed for tools like Vite:
- Allows imports without extensions
- Supports package.json
exportsfield - More permissive than
"node16"but safer than legacy"node"
Legacy option: "node" - still common but less accurate for modern bundlers
"allowImportingTsExtensions": true
What it does: Lets you import TypeScript files with explicit extensions.
Why enable? Allows explicit imports that some bundlers prefer:
// Now you can be explicit about imports
import { UserSchema } from './schemas/user.schema.ts';
import { Button } from '../components/Button.tsx';This is especially valuable in monorepos where different packages might have different build outputs
JavaScript Support
"allowJs": true
What it does: Lets TypeScript process JavaScript files.
Why enable?
- Gradual migration from JS to TS
- Third-party JS code in your source
- Configuration files often in JS
"checkJs": false
What it does: Type-checks JavaScript files.
Why disable?
- JS files might have looser patterns
- Avoid errors in config files
- Can enable per-file with
// @ts-check
🔒 Stricter option: Set to true for full type checking in JS files
Emit Configuration
"noEmit": true
What it does: Prevents TypeScript from generating output files.
Critical for React: Vite/esbuild handles compilation, TypeScript only type-checks. This prevents:
- Duplicate output files
- Compilation conflicts
- Slower builds
"sourceMap": true
What it does: Generates source maps for debugging.
Why enable? Better debugging experience - see original TS code in browser DevTools
Note: Only matters if noEmit: false
"jsx": "react-jsx"
What it does: How JSX is transformed.
Options explained:
"react-jsx": New transform (React 17+), no React import needed"react": Legacy, requiresimport React from 'react'"preserve": Leaves JSX unchanged (for other tools to handle)
The Strict Family
"strict": true
What it does: Enables ALL strict type-checking options:
strictNullChecks: null/undefined must be explicitstrictFunctionTypes: Stricter function compatibilitystrictBindCallApply: Type-check .bind(), .call(), .apply()strictPropertyInitialization: Class properties must be initializednoImplicitAny: No implicit any typesnoImplicitThis: ‘this’ must have explicit typealwaysStrict: Emit “use strict”useUnknownInCatchVariables: catch clause variables are ‘unknown’
This is non-negotiable for any serious TypeScript project.
"noImplicitOverride": true
What it does: Requires override keyword when overriding base class methods.
// Without this flag - silent bugs possible
class Dog extends Animal {
speak() {} // Did we mean to override? Typo?
}
// With flag - explicit intent
class Dog extends Animal {
override speak() {} // Clear we're overriding
}"noPropertyAccessFromIndexSignature": false
What it does: When true, forces bracket notation for index signatures.
Why we disable it:
interface Config {
[key: string]: string;
}
const config: Config = { name: 'app' };
// With flag true (stricter but annoying):
config['name']; // Required
config.name; // Error!
// With flag false (our choice):
config.name; // Allowed - better DX🔒 Stricter option: Enable for explicit index access patterns
"exactOptionalPropertyTypes": false
What it does: Distinguishes between undefined and missing properties.
Why we disable it:
interface User {
name?: string;
}
// With flag false (our choice):
const user: User = { name: undefined }; // Allowed
// With flag true (stricter):
const user: User = { name: undefined }; // Error!Most React code doesn’t distinguish these cases, enabling causes friction.
🔒 Stricter option: Enable for precise optional property handling
"noFallthroughCasesInSwitch": true
What it does: Errors on fallthrough cases in switch statements.
switch(action) {
case 'save':
save();
// Error! Did you forget 'break'?
case 'delete':
delete();
}Code Quality Checks
"noUnusedLocals": false / "noUnusedParameters": false
What it does: Errors on unused variables/parameters.
Why we disable:
- Annoying during development
- ESLint handles this better
- Can prefix with
_to indicate intentionally unused
🔒 Stricter option: Enable both for cleaner code
"noImplicitReturns": true
What it does: All code paths must explicitly return.
function calculate(n: number): number {
if (n > 0) {
return n * 2;
}
// Error! Missing return statement
}"allowUnreachableCode": false
What it does: Errors on code after return/throw/break.
function example() {
return 5;
console.log('Never runs'); // Error!
}Module Resolution Options
"esModuleInterop": true
What it does: Fixes CommonJS/ES module interoperability.
Enables:
// Without:
import * as React from 'react';
// With:
import React from 'react';"allowSyntheticDefaultImports": true
What it does: Allows default imports from modules without default export.
Note: Implied by esModuleInterop, but explicit is clearer.
"resolveJsonModule": true
What it does: Import JSON files as modules.
import config from './config.json';"isolatedModules": true
What it does: Ensures each file can be transpiled independently.
Critical for Vite/esbuild which transpile file-by-file. Catches:
- Files without imports/exports
- Type-only imports not marked as such
"moduleDetection": "force"
What it does: Treats all files as modules (not scripts).
Benefits:
- No accidental globals
- Consistent file treatment
- Required for top-level await
"verbatimModuleSyntax": true
What it does: Requires explicit type modifier for type-only imports.
// Bad - ambiguous
import { User } from './types';
// Good - explicit
import type { User } from './types';Why? Bundlers can tree-shake better, clearer intent.
Path Mapping
"baseUrl": "."
What it does: Base directory for non-relative imports.
"paths"
What it does: Custom module resolution paths.
// Instead of:
import Button from '../../../components/Button';
// You can:
import Button from '@components/Button';Best practices:
- Keep aliases focused and meaningful
- Mirror your folder structure
- Don’t overdo it (too many aliases = confusion)
Performance
"skipLibCheck": true
What it does: Skip type checking of declaration files (.d.ts).
Why enable?
- Faster compilation
- Avoid errors in third-party types
- You can’t fix node_modules anyway
🔒 Stricter option: Disable to catch all type issues
"incremental": true
What it does: Saves compilation info for faster subsequent builds.
"tsBuildInfoFile"
What it does: Where to store incremental compilation info.
Best practice: Use node_modules/.cache to keep project clean.
Environment Types
"types": ["vite/client"]
What it does: Which type packages to include globally.
Common additions:
"types": [
"vite/client", // Vite env variables
"node", // Node.js types if needed
"@testing-library/jest-dom", // Testing matchers
"vitest/globals" // If using Vitest
]Advanced: Multiple Config Strategy
Base config (tsconfig.json)
Your main configuration (shown above)
Node config (tsconfig.node.json)
For Vite config and Node.js scripts:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "ESNext",
"types": ["node"],
"allowJs": true
},
"include": ["vite.config.ts", "*.config.js"]
}Build config (tsconfig.build.json)
Stricter for production:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noUnusedLocals": true,
"noUnusedParameters": true
},
"exclude": ["**/*.test.tsx", "**/*.spec.tsx"]
}Server Components config (tsconfig.server.json)
If using React Server Components:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"lib": ["ES2023"], // No DOM for server
"types": ["node"] // Node.js types instead
},
"include": ["src/app/**/*server*", "src/lib/server/**/*"]
}Development vs Production Configs
You might want different strictness levels during development:
// tsconfig.dev.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noUnusedLocals": false,
"noUnusedParameters": false
}
}Then in your package.json:
{
"scripts": {
"dev": "vite --mode development",
"type-check": "tsc --project tsconfig.json --noEmit",
"type-check:dev": "tsc --project tsconfig.dev.json --noEmit",
"build": "tsc --project tsconfig.json --noEmit && vite build"
}
}Where We Chose Developer Experience Over Strictness
noUncheckedIndexedAccess (not included)
What it does: Makes indexed access return T | undefined
// Without noUncheckedIndexedAccess
const arr = [1, 2, 3];
const first = arr[0]; // Type: number (could be undefined!)
// With noUncheckedIndexedAccess
const first = arr[0]; // Type: number | undefined
// Requires constant checking even for "safe" accessReact 19 consideration: This option is particularly valuable with Server Components and dynamic imports:
// Without noUncheckedIndexedAccess - runtime error possible!
function getUser(users: User[], id: string) {
return users[parseInt(id)]; // Could be undefined!
}
// With noUncheckedIndexedAccess - TypeScript catches it
function getUser(users: User[], id: string) {
const user = users[parseInt(id)]; // Type: User | undefined
if (!user) throw new Error('User not found');
return user; // Now safely typed as User
}In my humble opinion, this is much friction for most React applications. Consider for high-reliability applications. Every time I try to be a good person and use this, I end up turning it off real fast.
noPropertyAccessFromIndexSignature: false
Allows natural property access on index signatures.
exactOptionalPropertyTypes: false
Doesn’t distinguish undefined from missing - matches most React patterns.
noUnusedLocals/Parameters: false
ESLint handles this better with more flexibility.
Decision Framework
Enable for safety:
- All
strictoptions noImplicitReturnsnoFallthroughCasesInSwitch
Enable for clarity:
verbatimModuleSyntaxnoImplicitOverrideisolatedModules
Consider per project:
noUncheckedIndexedAccess- High safety requirementsnoUnusedLocals/Parameters- Clean codebaseexactOptionalPropertyTypes- API precision
Always enable for React:
jsx: "react-jsx"noEmit: truemoduleResolution: "bundler"
Common Pitfalls
- Don’t use
"moduleResolution": "node"- It’s legacy - Don’t disable
strict- It’s a package deal - Don’t forget
isolatedModules- Required for Vite - Don’t mix build tools - Let Vite build, TypeScript check
Checking Your Config
Run these commands to verify:
# Type checking
npx tsc --noEmit
# See computed config
npx tsc --showConfig
# Check specific file
npx tsc --noEmit --explain-files | grep "yourfile.tsx"React 19 and Modern React Features
Supporting the use Hook
React 19’s use hook requires proper async typing:
// Ensure promises are well-typed for the use hook
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error('Failed to fetch user');
return response.json(); // Make sure this matches User type
}
function UserProfile({ userId }: { userId: string }) {
// TypeScript ensures the promise resolves to User
const user = use(fetchUser(userId));
return <div>{user.name}</div>;
}Concurrent Features Type Safety
React 19’s concurrent features benefit from strict async patterns. The configuration above ensures:
- Promises are properly typed
- Async boundaries are clear
- Suspense boundaries work correctly with TypeScript
Validating Your Configuration
Create a simple test file to verify your configuration works:
// test-config.tsx - Put this in your src folder temporarily
import { use } from 'react'; // React 19 import should work
import { Button } from './components/Button.tsx'; // Extension import should work
// This should type-check properly
const asyncData: Promise<{ name: string }> = Promise.resolve({ name: 'Test' });
function TestComponent() {
const data = use(asyncData); // Should be properly typed
return <Button variant="primary">{data.name}</Button>;
}
// Strictness test - this should error with your strict config
const maybeUser: { name: string } | undefined = undefined;
console.log(maybeUser.name); // Should error with strict checksRun tsc --noEmit to verify everything type-checks correctly, then delete the test file.
Keeping Your Config Updated
React and TypeScript evolve quickly. Here’s how to stay current:
- Follow React’s TypeScript integration updates in their release notes
- Monitor TypeScript’s release blog for new compiler options
- Use tools like
@typescript-eslint/parserto ensure your config stays compatible with your linting setup - Test your config with new TypeScript versions before upgrading
Remember: TypeScript is a tool to help you ship better code faster, not to fight with you. This config embodies that philosophy, while being ready for React 19’s latest features.