DEV_NET_CORE
GET_STARTED
ReactTypeScript for React

tsconfig basics, strict mode, and module settings

tsconfig basics, strict mode, and module settings

Overview

tsconfig.json is the main configuration file for a TypeScript project. It tells TypeScript which files belong to the project, how strictly the code should be checked, which JavaScript language features are available, how JSX should be handled, how modules should be interpreted, and whether TypeScript should emit JavaScript output.

In React projects, tsconfig.json is especially important because it affects developer experience, build correctness, editor IntelliSense, import behavior, JSX transformation, type checking, and compatibility with bundlers such as Vite, Webpack, Turbopack, or esbuild. A small configuration mistake can produce confusing import errors, missing DOM types, weak type safety, broken JSX support, or a mismatch between what TypeScript accepts and what the runtime or bundler actually supports.

For interviews, this topic matters because TypeScript configuration reveals whether a developer understands TypeScript as a toolchain, not only as a syntax layer. A strong React developer should know:

  • What tsconfig.json does.
  • What compilerOptions are.
  • What include, exclude, files, and extends do.
  • Why strict mode matters.
  • How strictness flags reduce runtime bugs.
  • How target, lib, jsx, module, and moduleResolution affect a React app.
  • Why modern React apps often use noEmit: true.
  • Why Vite-style projects often use moduleResolution: "bundler".
  • How path aliases work.
  • How to avoid common configuration mistakes.

A good interview answer should connect configuration choices to real development outcomes: safer code, clearer imports, better editor feedback, fewer production bugs, and fewer surprises between local development and production builds.

Core Concepts

What is tsconfig.json?

tsconfig.json is a project-level configuration file used by the TypeScript compiler and TypeScript language service. When a directory contains tsconfig.json, TypeScript treats that directory as the root of a TypeScript project.

A basic example:

Code
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "jsx": "react-jsx",
    "strict": true,
    "noEmit": true
  },
  "include": ["src"]
}

This configuration says:

  • Type-check files under src.
  • Assume modern JavaScript output target.
  • Use ES module syntax.
  • Use the modern React JSX transform.
  • Enable strict type checking.
  • Do not emit JavaScript from TypeScript itself.

In many React projects, the bundler handles code transformation and output. TypeScript is used mainly for type checking and editor support.

What belongs in compilerOptions?

compilerOptions contains most TypeScript behavior settings.

Common options include:

OptionPurpose
targetJavaScript version TypeScript should output or type-check against
libBuilt-in type libraries available to the project
jsxHow JSX should be transformed or preserved
strictEnables strict type-checking behavior
moduleModule format TypeScript assumes or emits
moduleResolutionHow TypeScript resolves imports
noEmitType-check without generating output
baseUrlBase directory for non-relative module resolution
pathsPath alias mappings
typesExplicit global type packages to include
skipLibCheckSkip type checking declaration files
isolatedModulesEnsure each file can be transpiled independently
allowJsAllow JavaScript files in the project
checkJsType-check JavaScript files

Example:

Code
{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "strict": true,
    "noEmit": true,
    "isolatedModules": true,
    "skipLibCheck": true
  }
}

include, exclude, and files

include, exclude, and files control which files belong to the TypeScript project.

include

include tells TypeScript which files or folders to include.

Code
{
  "include": ["src"]
}

This is common in React applications because source files are usually inside src.

You can use glob patterns:

Code
{
  "include": ["src/**/*.ts", "src/**/*.tsx"]
}

exclude

exclude removes files from the include pattern.

Code
{
  "exclude": ["node_modules", "dist", "coverage"]
}

Common exclusions:

  • node_modules
  • dist
  • build
  • coverage
  • generated files
  • test output folders

TypeScript excludes some folders like node_modules by default in many cases, but being explicit can improve clarity.

files

files explicitly lists exact files.

Code
{
  "files": ["src/main.tsx", "src/vite-env.d.ts"]
}

This is less common for normal React apps because the list can become hard to maintain. It is more useful for small tools, libraries, or specialized projects.

extends

extends lets one TypeScript configuration inherit from another.

Example:

Code
{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "jsx": "react-jsx"
  },
  "include": ["src"]
}

This is useful when a repository has multiple projects:

Code
tsconfig.base.json
tsconfig.app.json
tsconfig.node.json
tsconfig.test.json

A monorepo or Vite React app may use separate configs for application code and tooling code. For example, browser code and Node-based config files may need different module resolution and libraries.

tsconfig in React applications

A React TypeScript app usually needs:

  • TypeScript syntax support.
  • JSX support.
  • DOM types.
  • Modern JavaScript types.
  • Strict checking.
  • Module settings compatible with the bundler.
  • No TypeScript emit if the bundler emits JavaScript.
  • Path aliases if used by the project.

Example React/Vite-style app configuration:

Code
{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "allowJs": false,
    "skipLibCheck": true,

    "module": "ESNext",
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,

    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true
  },
  "include": ["src"]
}

Not every project needs every option. The correct config depends on the framework, bundler, runtime, TypeScript version, and team standards.

target

target controls which JavaScript language level TypeScript assumes when emitting output. Even when noEmit is true, target still affects type checking and available syntax assumptions.

Example:

Code
{
  "compilerOptions": {
    "target": "ES2020"
  }
}

Common target values include:

  • ES2018
  • ES2020
  • ES2021
  • ES2022
  • ESNext

For modern React apps using a bundler, ES2020 or newer is common. The bundler and browser support policy may further transform code for target browsers.

Important distinction:

Code
TypeScript target is not the same as browser support by itself.
A bundler or transpiler may also transform code based on browser targets.

lib

lib controls which built-in type declarations are available.

For React browser apps, common libraries are:

Code
{
  "compilerOptions": {
    "lib": ["ES2020", "DOM", "DOM.Iterable"]
  }
}

Meaning:

  • ES2020: JavaScript language APIs such as Promise, Map, Set, etc.
  • DOM: browser APIs such as document, window, fetch, HTMLElement.
  • DOM.Iterable: iterable DOM collections such as NodeListOf.

If DOM is missing, browser globals may fail:

Code
document.querySelector("#root");
// Error if DOM lib is not included

For Node-only projects, DOM may not be appropriate. This is one reason separate tsconfig files are useful for browser app code and Node tooling code.

jsx

jsx controls how TypeScript handles JSX syntax.

For modern React projects, this is common:

Code
{
  "compilerOptions": {
    "jsx": "react-jsx"
  }
}

react-jsx supports the modern JSX transform introduced in React 17, where importing React just to use JSX is not required.

Example:

Code
function App() {
  return <h1>Hello</h1>;
}

With modern JSX transform, this can work without:

Code
import React from "react";

Older React projects may use:

Code
{
  "compilerOptions": {
    "jsx": "react"
  }
}

Some bundler setups may use:

Code
{
  "compilerOptions": {
    "jsx": "preserve"
  }
}

That keeps JSX in the output for another tool to transform.

For most modern React app projects, react-jsx is the practical default.

strict

strict enables a group of stricter type-checking rules.

Code
{
  "compilerOptions": {
    "strict": true
  }
}

This is one of the most important TypeScript options. It makes TypeScript better at catching possible bugs before runtime.

Strict mode usually enables flags such as:

  • noImplicitAny
  • strictNullChecks
  • strictFunctionTypes
  • strictBindCallApply
  • strictPropertyInitialization
  • noImplicitThis
  • alwaysStrict
  • useUnknownInCatchVariables

The exact set of strict flags can evolve across TypeScript versions, so treat strict as "enable TypeScript's current strict family of checks."

Why strict mode matters

Without strict mode, TypeScript allows more unsafe code. That can make migration easier but weakens the value of TypeScript.

Example without strictNullChecks:

Code
type User = {
  name: string;
};

function getDisplayName(user: User | null) {
  return user.name;
}

Without strict null checking, this may compile. At runtime, it can crash if user is null.

With strict mode:

Code
function getDisplayName(user: User | null) {
  if (!user) {
    return "Guest";
  }

  return user.name;
}

Strict mode encourages developers to handle missing data, unknown inputs, incorrect function calls, and incomplete object initialization.

In React, strict mode helps catch issues with:

  • Optional props.
  • Nullable API data.
  • Event handler types.
  • State initialized as null.
  • Refs that may not be assigned yet.
  • Context values that may be missing.
  • Async data before it loads.

noImplicitAny

noImplicitAny reports an error when TypeScript cannot infer a type and would otherwise use any.

Bad:

Code
function formatUser(user) {
  return user.name.toUpperCase();
}

With noImplicitAny, user cannot silently become any.

Better:

Code
type User = {
  name: string;
};

function formatUser(user: User) {
  return user.name.toUpperCase();
}

This prevents weak typing from spreading through the application.

strictNullChecks

strictNullChecks treats null and undefined as distinct values that must be handled.

Example:

Code
type User = {
  id: string;
  email?: string;
};

function sendEmail(user: User) {
  user.email.toLowerCase();
}

With strict null checks, this is unsafe because email may be undefined.

Correct:

Code
function sendEmail(user: User) {
  if (!user.email) {
    return;
  }

  user.email.toLowerCase();
}

In React, this is extremely useful for async data:

Code
type User = {
  id: string;
  name: string;
};

function Profile({ user }: { user: User | null }) {
  if (!user) {
    return <p>Loading...</p>;
  }

  return <p>{user.name}</p>;
}

Optional properties vs nullable values

TypeScript distinguishes optional properties and explicit nullable values.

Optional:

Code
type User = {
  email?: string;
};

This means email may be missing or undefined.

Nullable:

Code
type User = {
  email: string | null;
};

This means the property exists, but the value can be null.

Optional or nullable:

Code
type User = {
  email?: string | null;
};

This means the property may be missing, undefined, null, or a string.

In API contracts, prefer modeling the actual backend behavior accurately. Do not use optional fields just because it is convenient.

exactOptionalPropertyTypes

exactOptionalPropertyTypes makes optional properties stricter.

Code
{
  "compilerOptions": {
    "exactOptionalPropertyTypes": true
  }
}

Without this option, an optional property can often be treated like it allows undefined.

Example:

Code
type ButtonProps = {
  label?: string;
};

const props: ButtonProps = {
  label: undefined
};

With exactOptionalPropertyTypes, this is stricter: an optional property means the property may be absent, not necessarily explicitly set to undefined, unless undefined is included in the type.

More explicit:

Code
type ButtonProps = {
  label?: string | undefined;
};

This option can improve accuracy but may require more careful typing, especially with partial objects and object spreading.

noUncheckedIndexedAccess

noUncheckedIndexedAccess makes indexed access safer.

Code
{
  "compilerOptions": {
    "noUncheckedIndexedAccess": true
  }
}

Example:

Code
const users = ["Alice", "Bob"];

const first = users[0];
const third = users[2];

With this option, users[2] has type string | undefined, because an array access can be out of range.

This is safer:

Code
const third = users[2];

if (third) {
  console.log(third.toUpperCase());
}

This option is useful for robust code but can be noisy in some React apps. Teams often enable it for stricter projects.

useUnknownInCatchVariables

With this strict behavior, catch variables are unknown instead of any.

Code
try {
  throw new Error("Failed");
} catch (error) {
  console.log(error.message);
}

This is unsafe because anything can be thrown in JavaScript.

Better:

Code
try {
  throw new Error("Failed");
} catch (error) {
  if (error instanceof Error) {
    console.log(error.message);
  } else {
    console.log("Unknown error", error);
  }
}

This matters in React API error handling because not all thrown values are guaranteed to be Error objects.

strictFunctionTypes

strictFunctionTypes makes function type compatibility safer, especially around callback parameter types.

Example:

Code
type Animal = {
  name: string;
};

type Dog = Animal & {
  bark(): void;
};

let handleAnimal: (animal: Animal) => void;

const handleDog = (dog: Dog) => {
  dog.bark();
};

handleAnimal = handleDog;

This assignment is unsafe because handleAnimal might be called with an Animal that is not a Dog.

Strict function checking helps prevent incorrect callback assumptions, which is important in React event handlers, generic components, and reusable UI libraries.

strictPropertyInitialization

strictPropertyInitialization ensures class properties are initialized.

Bad:

Code
class UserStore {
  currentUser: string;

  constructor() {
    // currentUser is not initialized
  }
}

Correct:

Code
class UserStore {
  currentUser: string | null = null;
}

Or:

Code
class UserStore {
  currentUser: string;

  constructor(currentUser: string) {
    this.currentUser = currentUser;
  }
}

This is less common in function-component React apps but still useful in services, stores, classes, and utility code.

Avoiding any

any disables type checking for a value. It is useful for gradual migration or truly dynamic boundaries, but it should not become the default.

Bad:

Code
function renderUser(user: any) {
  return user.profile.name.toUpperCase();
}

This can crash at runtime and TypeScript will not help.

Better:

Code
type User = {
  profile: {
    name: string;
  };
};

function renderUser(user: User) {
  return user.profile.name.toUpperCase();
}

For unknown external data, use unknown first and validate.

Code
function parseUser(value: unknown): User {
  if (
    typeof value === "object" &&
    value !== null &&
    "profile" in value
  ) {
    return value as User;
  }

  throw new Error("Invalid user");
}

In production, schema validation libraries can help validate API responses.

unknown vs any

unknown is safer than any.

Code
function handleValue(value: unknown) {
  value.toUpperCase();
}

This fails because TypeScript does not know that value is a string.

Correct:

Code
function handleValue(value: unknown) {
  if (typeof value === "string") {
    return value.toUpperCase();
  }

  return "";
}

Comparison:

TypeMeaning
anyTurn off type checking for this value
unknownValue can be anything, but must be narrowed before use

Use unknown for external input, caught errors, JSON parsing, and uncertain boundaries.

noEmit

noEmit tells TypeScript not to output JavaScript files.

Code
{
  "compilerOptions": {
    "noEmit": true
  }
}

This is common in React apps because the bundler handles transformation and output.

Typical workflow:

Code
tsc --noEmit
vite build

Meaning:

  • tsc --noEmit checks types.
  • vite build bundles/transforms application code.

This separation is common because TypeScript type checking and bundler output are different responsibilities.

isolatedModules

isolatedModules warns when code cannot be safely transpiled one file at a time.

Code
{
  "compilerOptions": {
    "isolatedModules": true
  }
}

This is useful with bundlers and transpilers that process each file independently, such as Babel, esbuild, or SWC.

Example issue:

Code
const enum Direction {
  Up,
  Down
}

Certain TypeScript-only features can be problematic when the transpiler does not do full type-aware compilation. isolatedModules helps catch patterns that may not work correctly in those pipelines.

skipLibCheck

skipLibCheck skips type checking of declaration files.

Code
{
  "compilerOptions": {
    "skipLibCheck": true
  }
}

This can speed up builds and avoid type errors inside third-party .d.ts files.

Trade-off:

  • Faster and less noisy.
  • May hide declaration-file issues from dependencies.
  • Does not skip checking your own source code.

Many React projects use skipLibCheck: true for practical build performance.

module

module controls the module system TypeScript uses for output and type analysis.

Common values include:

  • ESNext
  • ES2020
  • NodeNext
  • CommonJS
  • Preserve

For modern React apps with a bundler, a common setting is:

Code
{
  "compilerOptions": {
    "module": "ESNext"
  }
}

This preserves modern ES module syntax for the bundler.

For modern Node.js projects, NodeNext is often more appropriate because Node has specific ESM and CommonJS rules.

Important:

Code
module controls how TypeScript understands or emits modules.
moduleResolution controls how TypeScript finds imported modules.

They are related, but they are not the same.

moduleResolution

moduleResolution controls how TypeScript resolves imports.

Example import:

Code
import { formatDate } from "@/utils/date";
import React from "react";

TypeScript must decide what file or package those imports refer to.

Common values include:

  • bundler
  • node16
  • nodenext
  • node
  • classic

For modern React apps using Vite or a similar bundler, this is common:

Code
{
  "compilerOptions": {
    "moduleResolution": "bundler"
  }
}

bundler mode is designed for bundler-based projects. It models the way modern bundlers handle package exports, imports, and extensionless imports.

For Node.js projects, use Node-specific settings such as node16 or nodenext when the code runs directly in Node and must follow Node's module rules.

module vs moduleResolution

These options are often confused.

OptionQuestion it answers
moduleWhat module system should TypeScript assume or emit?
moduleResolutionHow should TypeScript find files and packages from import paths?

Example:

Code
{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "bundler"
  }
}

This means:

  • Keep ES module syntax for the bundler.
  • Resolve imports using bundler-style rules.

For React/Vite apps, this is common. For Node apps, this may be wrong because the runtime itself is Node, not a bundler.

baseUrl and paths

baseUrl and paths are used for path aliases.

Example:

Code
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@/components/*": ["src/components/*"]
    }
  }
}

Then code can import:

Code
import { Button } from "@/components/Button";
import { formatDate } from "@/utils/date";

instead of:

Code
import { Button } from "../../../../components/Button";

Important: TypeScript path aliases help TypeScript and the editor understand imports, but the runtime or bundler must also understand them.

In Vite, configure aliases separately:

Code
import path from "node:path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src")
    }
  }
});

Common mistake:

Code
Adding paths in tsconfig but not configuring the bundler.

This can make the editor happy while the app fails at runtime or build time.

types

types controls which global type packages are included.

Example for Vite:

Code
{
  "compilerOptions": {
    "types": ["vite/client"]
  }
}

This makes Vite-specific types available, such as import.meta.env.

Example:

Code
const apiUrl = import.meta.env.VITE_API_URL;

For testing, you might have a separate test config:

Code
{
  "compilerOptions": {
    "types": ["vitest/globals", "@testing-library/jest-dom"]
  }
}

Be careful: when types is specified, TypeScript includes only the listed global type packages. This can accidentally remove expected global types if configured incorrectly.

allowJs and checkJs

allowJs lets JavaScript files be part of the TypeScript project.

Code
{
  "compilerOptions": {
    "allowJs": true
  }
}

checkJs enables type checking for JavaScript files.

Code
{
  "compilerOptions": {
    "allowJs": true,
    "checkJs": true
  }
}

These are useful for gradual migration from JavaScript to TypeScript.

Example JavaScript with JSDoc:

Code
/**
 * @param {number} price
 * @returns {string}
 */
export function formatPrice(price) {
  return `$${price.toFixed(2)}`;
}

For new React TypeScript projects, allowJs is usually false unless migration is needed.

resolveJsonModule

resolveJsonModule allows importing JSON files.

Code
{
  "compilerOptions": {
    "resolveJsonModule": true
  }
}

Example:

Code
import packageInfo from "../package.json";

console.log(packageInfo.version);

This is useful for configuration, mock data, localization files, or metadata.

allowImportingTsExtensions

allowImportingTsExtensions allows imports that include .ts, .tsx, .mts, or .cts extensions.

Code
{
  "compilerOptions": {
    "allowImportingTsExtensions": true,
    "noEmit": true
  }
}

This is only safe in configurations where TypeScript is not responsible for emitting runnable JavaScript, or where declarations only are emitted. It is common in bundler-based setups but not always needed.

Example:

Code
import { helper } from "./helper.ts";

Many projects still prefer extensionless imports:

Code
import { helper } from "./helper";

Follow the convention expected by your bundler and runtime.

verbatimModuleSyntax

verbatimModuleSyntax keeps import/export syntax closer to what you wrote and makes type-only imports more explicit.

Code
{
  "compilerOptions": {
    "verbatimModuleSyntax": true
  }
}

Example:

Code
import type { User } from "./types";
import { fetchUser } from "./api";

import type is erased from output because it is used only for types.

This improves clarity:

  • Type-only imports are explicit.
  • Runtime imports are easier to distinguish.
  • Bundlers can better understand what code is needed at runtime.
  • Accidental runtime imports for type-only usage are reduced.

Type-only imports

A type-only import imports only TypeScript types, not runtime values.

Code
import type { Product } from "@/types/product";

Use it when importing a type:

Code
type ProductCardProps = {
  product: Product;
};

Use normal imports for runtime values:

Code
import { formatCurrency } from "@/utils/formatCurrency";

This distinction matters because TypeScript types disappear at runtime. Importing a type as a runtime value can cause confusing module behavior.

esModuleInterop and allowSyntheticDefaultImports

These options affect compatibility with CommonJS-style modules and default imports.

Example:

Code
{
  "compilerOptions": {
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true
  }
}

These options often help when importing older CommonJS packages.

Example:

Code
import express from "express";

In React app code, this is less visible than in Node.js backend code, but it can still appear when using older packages.

The best setting depends on the toolchain and project type. Many modern templates enable compatibility options to reduce import friction.

forceConsistentCasingInFileNames

This option catches import casing mistakes.

Code
{
  "compilerOptions": {
    "forceConsistentCasingInFileNames": true
  }
}

Example problem:

Code
import { Button } from "./components/button";

But the file is:

Code
components/Button.tsx

This may work on a case-insensitive local file system such as Windows or macOS default settings, but fail in Linux CI or production. This option helps catch the mismatch early.

noUnusedLocals and noUnusedParameters

These options report unused variables and parameters.

Code
{
  "compilerOptions": {
    "noUnusedLocals": true,
    "noUnusedParameters": true
  }
}

Example:

Code
function calculateTotal(price: number, discount: number) {
  const tax = 0.1;

  return price - discount;
}

tax is unused.

These options keep code clean, but some teams prefer ESLint to handle unused code. Avoid duplicate or conflicting rules between TypeScript and ESLint.

noFallthroughCasesInSwitch

This option catches accidental fallthrough in switch statements.

Code
{
  "compilerOptions": {
    "noFallthroughCasesInSwitch": true
  }
}

Bad:

Code
switch (status) {
  case "loading":
    showSpinner();
  case "success":
    showData();
    break;
}

Without break, code falls through from "loading" to "success".

This is useful in reducers, state machines, and UI logic.

A practical React/Vite application config may look like this:

Code
{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],

    "module": "ESNext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,

    "jsx": "react-jsx",
    "strict": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,

    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    },

    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true
  },
  "include": ["src"]
}

This is a solid starting point, but not a universal answer. Frameworks and templates may recommend slightly different settings.

Separate app and Node/tooling configs

React projects often contain both browser code and Node-based tooling code.

Example files:

Code
src/main.tsx
src/App.tsx
vite.config.ts
eslint.config.js

Browser code needs DOM types. Vite config runs in Node and may need Node-specific types and module resolution.

A project may use separate configs:

Code
tsconfig.json
tsconfig.app.json
tsconfig.node.json

Example root config:

Code
{
  "files": [],
  "references": [
    { "path": "./tsconfig.app.json" },
    { "path": "./tsconfig.node.json" }
  ]
}

Example app config:

Code
{
  "compilerOptions": {
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "jsx": "react-jsx",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "noEmit": true
  },
  "include": ["src"]
}

Example Node/tooling config:

Code
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022"],
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "types": ["node"],
    "strict": true,
    "noEmit": true
  },
  "include": ["vite.config.ts"]
}

This avoids mixing browser and Node assumptions in one config.

Project references

Project references help TypeScript understand multiple related projects.

Example:

Code
{
  "files": [],
  "references": [
    { "path": "./packages/ui" },
    { "path": "./packages/app" }
  ]
}

They are common in monorepos, libraries, and large applications.

Benefits:

  • Faster incremental builds.
  • Clear project boundaries.
  • Better editor performance in large workspaces.
  • Separate configs for app, library, tests, and tooling.

Most small React apps do not need project references beyond what a template provides.

TypeScript and bundlers

In many React projects, TypeScript is not the tool that produces the final JavaScript bundle. Instead:

  • TypeScript checks types.
  • The bundler transforms and bundles files.
  • The bundler handles JSX, CSS imports, assets, environment variables, and code splitting.

Example scripts:

Code
{
  "scripts": {
    "dev": "vite",
    "typecheck": "tsc --noEmit",
    "build": "tsc --noEmit && vite build"
  }
}

This ensures the production build fails if type checking fails.

Common module setting combinations

Common combinations:

Project typeCommon settings
React app with Vitemodule: "ESNext", moduleResolution: "bundler", noEmit: true
Modern Node appmodule: "NodeNext", moduleResolution: "NodeNext"
Older Node/CommonJS appmodule: "CommonJS", moduleResolution: "node"
Library packageDepends on emitted format, package exports, and build tool
MonorepoShared base config plus project-specific configs

Do not copy module settings blindly. The correct settings depend on where the code runs and what tool performs the final build.

moduleResolution: "bundler" in React apps

moduleResolution: "bundler" is designed for projects where a bundler resolves modules. It supports modern package resolution behavior used by bundlers and avoids enforcing some Node runtime restrictions that do not apply to bundled browser code.

It is commonly appropriate when:

  • The code is bundled before running.
  • You use Vite, Webpack, Rollup, esbuild, or similar tools.
  • You use modern package exports.
  • You write extensionless imports that the bundler supports.
  • TypeScript is used for checking, not direct runtime execution.

It may be inappropriate when:

  • The TypeScript output runs directly in Node.
  • You are building a Node library that must match Node's ESM behavior.
  • You rely on Node-specific module rules.
  • Your runtime is not a bundler.

module: "NodeNext" and moduleResolution: "NodeNext"

For code that runs directly in Node.js using modern module rules, NodeNext is often appropriate.

Code
{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext"
  }
}

This tells TypeScript to follow Node's ESM/CommonJS behavior, including package type, file extensions, and package exports.

This is more common for:

  • Node backend code.
  • CLI tools.
  • Vite config or build tooling code.
  • Server-side scripts.
  • Packages intended to run directly in Node.

For React browser app code bundled by Vite, bundler mode is often simpler.

Path aliases and runtime compatibility

TypeScript path aliases are not enough by themselves.

Example TypeScript config:

Code
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  }
}

This makes TypeScript understand:

Code
import { Button } from "@/components/Button";

But Vite also needs:

Code
resolve: {
  alias: {
    "@": path.resolve(__dirname, "./src")
  }
}

Testing tools may also need alias configuration. For example, Vitest, Jest, Storybook, or ESLint may need equivalent settings.

Common problem:

Code
Editor has no error, but build or tests fail because aliases are configured only in tsconfig.

Environment variables and Vite types

Vite exposes environment variables through import.meta.env.

Example:

Code
const apiUrl = import.meta.env.VITE_API_URL;

To type this correctly, include Vite client types.

In a vite-env.d.ts file:

Code
/// <reference types="vite/client" />

Or in tsconfig:

Code
{
  "compilerOptions": {
    "types": ["vite/client"]
  }
}

For custom environment variables, define types:

Code
interface ImportMetaEnv {
  readonly VITE_API_URL: string;
  readonly VITE_APP_NAME: string;
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}

Only variables with the expected public prefix should be exposed to browser code. Do not put secrets in frontend environment variables.

tsc --noEmit in CI

A common CI step:

Code
npm run typecheck

Script:

Code
{
  "scripts": {
    "typecheck": "tsc --noEmit"
  }
}

This catches type errors even if the dev server is permissive or transpiles without full type checking.

Good CI flow:

Code
npm ci
npm run lint
npm run typecheck
npm run test
npm run build

TypeScript should be part of the quality gate, not only an editor feature.

Common mistakes

Common tsconfig mistakes include:

  • Turning off strict to avoid fixing real type issues.
  • Using any instead of modeling data correctly.
  • Missing DOM in lib for React browser apps.
  • Using Node module settings for browser code without understanding the trade-off.
  • Using moduleResolution: "bundler" for code that runs directly in Node.
  • Adding path aliases to tsconfig but not to Vite, Jest, Storybook, or other tools.
  • Setting types and accidentally excluding needed global types.
  • Assuming TypeScript path aliases change runtime behavior automatically.
  • Forgetting noEmit when a bundler should own output.
  • Using one tsconfig for both browser code and Node tooling when their environments differ.
  • Ignoring forceConsistentCasingInFileNames and later failing in Linux CI.
  • Depending on skipLibCheck to hide real project type errors.
  • Making useEffect or component typing worse because props and state types are too loose.
  • Not running tsc --noEmit in CI.
  • Copying config from another framework without adapting it.

Best practices

Good TypeScript configuration habits for React include:

  • Enable strict for new projects.
  • Use noEmit: true when the bundler emits output.
  • Use jsx: "react-jsx" for modern React projects.
  • Include DOM and DOM.Iterable in browser app lib.
  • Use moduleResolution: "bundler" for modern bundled React app code when appropriate.
  • Use Node-specific module settings for Node tooling code.
  • Keep browser and Node configs separate when needed.
  • Use forceConsistentCasingInFileNames: true.
  • Use baseUrl and paths only with matching bundler/test configuration.
  • Prefer unknown over any at external boundaries.
  • Consider stricter flags such as noUncheckedIndexedAccess and exactOptionalPropertyTypes for mature projects.
  • Use import type for type-only imports.
  • Keep tsconfig small enough to understand.
  • Add comments in documentation if the team uses uncommon settings.
  • Run type checking in CI.

Practical decision guide

Use this guide when reviewing or designing a React TypeScript config:

Code
Is this a browser React app?
  -> Include DOM libs and JSX settings.

Is a bundler producing the final output?
  -> Use noEmit and bundler-compatible module settings.

Is this code running directly in Node?
  -> Use Node-specific module and moduleResolution settings.

Is this a new project?
  -> Start with strict mode enabled.

Is this a migration from JavaScript?
  -> Consider allowJs/checkJs temporarily and tighten over time.

Are import aliases used?
  -> Configure both tsconfig paths and the bundler/test tools.

Are type errors hidden by any?
  -> Prefer unknown, explicit types, and validation at boundaries.

Is CI running type checks?
  -> Add tsc --noEmit as a required step.

The best TypeScript configuration is not the longest configuration. It is the configuration that accurately matches the runtime, the bundler, the team's strictness goals, and the way the project is deployed.

Interview Practice

PreviousNarrowing and control-flow analysisNext UpTypes, unions, intersections, and discriminated unions