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.jsondoes. - What
compilerOptionsare. - What
include,exclude,files, andextendsdo. - Why
strictmode matters. - How strictness flags reduce runtime bugs.
- How
target,lib,jsx,module, andmoduleResolutionaffect 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:
{
"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:
Example:
{
"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.
{
"include": ["src"]
}
This is common in React applications because source files are usually inside src.
You can use glob patterns:
{
"include": ["src/**/*.ts", "src/**/*.tsx"]
}
exclude
exclude removes files from the include pattern.
{
"exclude": ["node_modules", "dist", "coverage"]
}
Common exclusions:
node_modulesdistbuildcoverage- 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.
{
"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:
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx"
},
"include": ["src"]
}
This is useful when a repository has multiple projects:
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:
{
"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:
{
"compilerOptions": {
"target": "ES2020"
}
}
Common target values include:
ES2018ES2020ES2021ES2022ESNext
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:
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:
{
"compilerOptions": {
"lib": ["ES2020", "DOM", "DOM.Iterable"]
}
}
Meaning:
ES2020: JavaScript language APIs such as Promise, Map, Set, etc.DOM: browser APIs such asdocument,window,fetch,HTMLElement.DOM.Iterable: iterable DOM collections such asNodeListOf.
If DOM is missing, browser globals may fail:
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:
{
"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:
function App() {
return <h1>Hello</h1>;
}
With modern JSX transform, this can work without:
import React from "react";
Older React projects may use:
{
"compilerOptions": {
"jsx": "react"
}
}
Some bundler setups may use:
{
"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.
{
"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:
noImplicitAnystrictNullChecksstrictFunctionTypesstrictBindCallApplystrictPropertyInitializationnoImplicitThisalwaysStrictuseUnknownInCatchVariables
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:
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:
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:
function formatUser(user) {
return user.name.toUpperCase();
}
With noImplicitAny, user cannot silently become any.
Better:
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:
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:
function sendEmail(user: User) {
if (!user.email) {
return;
}
user.email.toLowerCase();
}
In React, this is extremely useful for async data:
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:
type User = {
email?: string;
};
This means email may be missing or undefined.
Nullable:
type User = {
email: string | null;
};
This means the property exists, but the value can be null.
Optional or nullable:
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.
{
"compilerOptions": {
"exactOptionalPropertyTypes": true
}
}
Without this option, an optional property can often be treated like it allows undefined.
Example:
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:
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.
{
"compilerOptions": {
"noUncheckedIndexedAccess": true
}
}
Example:
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:
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.
try {
throw new Error("Failed");
} catch (error) {
console.log(error.message);
}
This is unsafe because anything can be thrown in JavaScript.
Better:
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:
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:
class UserStore {
currentUser: string;
constructor() {
// currentUser is not initialized
}
}
Correct:
class UserStore {
currentUser: string | null = null;
}
Or:
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:
function renderUser(user: any) {
return user.profile.name.toUpperCase();
}
This can crash at runtime and TypeScript will not help.
Better:
type User = {
profile: {
name: string;
};
};
function renderUser(user: User) {
return user.profile.name.toUpperCase();
}
For unknown external data, use unknown first and validate.
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.
function handleValue(value: unknown) {
value.toUpperCase();
}
This fails because TypeScript does not know that value is a string.
Correct:
function handleValue(value: unknown) {
if (typeof value === "string") {
return value.toUpperCase();
}
return "";
}
Comparison:
Use unknown for external input, caught errors, JSON parsing, and uncertain boundaries.
noEmit
noEmit tells TypeScript not to output JavaScript files.
{
"compilerOptions": {
"noEmit": true
}
}
This is common in React apps because the bundler handles transformation and output.
Typical workflow:
tsc --noEmit
vite build
Meaning:
tsc --noEmitchecks types.vite buildbundles/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.
{
"compilerOptions": {
"isolatedModules": true
}
}
This is useful with bundlers and transpilers that process each file independently, such as Babel, esbuild, or SWC.
Example issue:
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.
{
"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:
ESNextES2020NodeNextCommonJSPreserve
For modern React apps with a bundler, a common setting is:
{
"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:
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:
import { formatDate } from "@/utils/date";
import React from "react";
TypeScript must decide what file or package those imports refer to.
Common values include:
bundlernode16nodenextnodeclassic
For modern React apps using Vite or a similar bundler, this is common:
{
"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.
Example:
{
"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:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@/components/*": ["src/components/*"]
}
}
}
Then code can import:
import { Button } from "@/components/Button";
import { formatDate } from "@/utils/date";
instead of:
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:
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:
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:
{
"compilerOptions": {
"types": ["vite/client"]
}
}
This makes Vite-specific types available, such as import.meta.env.
Example:
const apiUrl = import.meta.env.VITE_API_URL;
For testing, you might have a separate test config:
{
"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.
{
"compilerOptions": {
"allowJs": true
}
}
checkJs enables type checking for JavaScript files.
{
"compilerOptions": {
"allowJs": true,
"checkJs": true
}
}
These are useful for gradual migration from JavaScript to TypeScript.
Example JavaScript with JSDoc:
/**
* @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.
{
"compilerOptions": {
"resolveJsonModule": true
}
}
Example:
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.
{
"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:
import { helper } from "./helper.ts";
Many projects still prefer extensionless imports:
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.
{
"compilerOptions": {
"verbatimModuleSyntax": true
}
}
Example:
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.
import type { Product } from "@/types/product";
Use it when importing a type:
type ProductCardProps = {
product: Product;
};
Use normal imports for runtime values:
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:
{
"compilerOptions": {
"esModuleInterop": true,
"allowSyntheticDefaultImports": true
}
}
These options often help when importing older CommonJS packages.
Example:
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.
{
"compilerOptions": {
"forceConsistentCasingInFileNames": true
}
}
Example problem:
import { Button } from "./components/button";
But the file is:
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.
{
"compilerOptions": {
"noUnusedLocals": true,
"noUnusedParameters": true
}
}
Example:
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.
{
"compilerOptions": {
"noFallthroughCasesInSwitch": true
}
}
Bad:
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.
Recommended React app config example
A practical React/Vite application config may look like this:
{
"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:
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:
tsconfig.json
tsconfig.app.json
tsconfig.node.json
Example root config:
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
Example app config:
{
"compilerOptions": {
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true
},
"include": ["src"]
}
Example Node/tooling config:
{
"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:
{
"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:
{
"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:
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.
{
"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:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}
This makes TypeScript understand:
import { Button } from "@/components/Button";
But Vite also needs:
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:
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:
const apiUrl = import.meta.env.VITE_API_URL;
To type this correctly, include Vite client types.
In a vite-env.d.ts file:
/// <reference types="vite/client" />
Or in tsconfig:
{
"compilerOptions": {
"types": ["vite/client"]
}
}
For custom environment variables, define types:
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:
npm run typecheck
Script:
{
"scripts": {
"typecheck": "tsc --noEmit"
}
}
This catches type errors even if the dev server is permissive or transpiles without full type checking.
Good CI flow:
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
strictto avoid fixing real type issues. - Using
anyinstead of modeling data correctly. - Missing
DOMinlibfor 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
tsconfigbut not to Vite, Jest, Storybook, or other tools. - Setting
typesand accidentally excluding needed global types. - Assuming TypeScript path aliases change runtime behavior automatically.
- Forgetting
noEmitwhen a bundler should own output. - Using one
tsconfigfor both browser code and Node tooling when their environments differ. - Ignoring
forceConsistentCasingInFileNamesand later failing in Linux CI. - Depending on
skipLibCheckto hide real project type errors. - Making
useEffector component typing worse because props and state types are too loose. - Not running
tsc --noEmitin CI. - Copying config from another framework without adapting it.
Best practices
Good TypeScript configuration habits for React include:
- Enable
strictfor new projects. - Use
noEmit: truewhen the bundler emits output. - Use
jsx: "react-jsx"for modern React projects. - Include
DOMandDOM.Iterablein browser applib. - 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
baseUrlandpathsonly with matching bundler/test configuration. - Prefer
unknownoveranyat external boundaries. - Consider stricter flags such as
noUncheckedIndexedAccessandexactOptionalPropertyTypesfor mature projects. - Use
import typefor type-only imports. - Keep
tsconfigsmall 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:
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.