Steve Kinney

tsconfig.json Deep Dive for React

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 exports field
  • 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, requires import 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 explicit
  • strictFunctionTypes: Stricter function compatibility
  • strictBindCallApply: Type-check .bind(), .call(), .apply()
  • strictPropertyInitialization: Class properties must be initialized
  • noImplicitAny: No implicit any types
  • noImplicitThis: ‘this’ must have explicit type
  • alwaysStrict: 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" access

React 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 strict options
  • noImplicitReturns
  • noFallthroughCasesInSwitch

Enable for clarity:

  • verbatimModuleSyntax
  • noImplicitOverride
  • isolatedModules

Consider per project:

  • noUncheckedIndexedAccess - High safety requirements
  • noUnusedLocals/Parameters - Clean codebase
  • exactOptionalPropertyTypes - API precision

Always enable for React:

  • jsx: "react-jsx"
  • noEmit: true
  • moduleResolution: "bundler"

Common Pitfalls

  1. Don’t use "moduleResolution": "node" - It’s legacy
  2. Don’t disable strict - It’s a package deal
  3. Don’t forget isolatedModules - Required for Vite
  4. 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 checks

Run 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:

  1. Follow React’s TypeScript integration updates in their release notes
  2. Monitor TypeScript’s release blog for new compiler options
  3. Use tools like @typescript-eslint/parser to ensure your config stays compatible with your linting setup
  4. 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.

Last modified on .