Lesson 7

Modules & tooling

Home › Courses › TypeScript › Lesson 7
🔷 Lesson 7 · 20 minutes

Modules & tooling.
From .ts file to shipped app.

Everything up to now has been syntax. This lesson is about the layer underneath: how TypeScript compiles, how to configure it, how to structure a project, and how it integrates with the bundlers and frameworks you'll actually use. tsconfig.json in depth — the 10 options you need and the 50 you can ignore. ESM vs CommonJS. Path aliases that keep imports sane. .d.ts files and @types/* packages. Vite + TypeScript, the modern setup. This is the lesson that takes you from "I understand the syntax" to "I can ship a TypeScript project."

~20 minutes Intermediate TypeScript 5 · Vite 5 · tsc
TypeScript path tsconfig.json ESM vs CommonJS Path aliases .d.ts & @types Vite + TS setup Challenge

tsconfig.json configures the compiler. The essential options: strict: true (turn this on day one), target: "ES2022", module: "ESNext", moduleResolution: "bundler", noEmit: true (when using a bundler), paths for aliases. ESM (import/export) is the modern standard; CommonJS (require) is legacy. Path aliases (@/ → src/) need config in both tsconfig and your bundler. .d.ts files describe JS libraries' types. Vite compiles TS with esbuild during dev; tsc --noEmit type-checks separately (run in CI).

Before this lesson: complete Lesson 6 — you've seen every type-level feature you'll need. Now we connect it to real tooling.

⚙️ tsconfig.json — the 10 options you need

A full tsconfig has ~100 options. You'll actually use about 10.

tsconfig.json is the TypeScript compiler configuration. It's a JSON file at the project root, and every tool (tsc, Vite, Next.js, VS Code) reads it. Getting it right on day one saves months of pain later. Getting it wrong — especially strict — causes the exact bugs TypeScript exists to prevent.

tsconfig.json
{
// ─── The one that matters most ────────────
"compilerOptions": {
"strict": true,

// ─── Output target ──────────────────────
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],

// ─── Module system ──────────────────────
"module": "ESNext",
"moduleResolution": "bundler",

// ─── Compatibility flags ────────────────
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,

// ─── Emit behavior ──────────────────────
"noEmit": true,
"skipLibCheck": true,

// ─── Path aliases ───────────────────────
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
// ─── What each option does ─────────────────

// strict: true
// Turns on 8 sub-flags at once: strictNullChecks,
// noImplicitAny, strictFunctionTypes, etc. Catches
// null/undefined bugs, implicit any, and more.
// ALWAYS enable this from day one.

// target: "ES2022"
// JavaScript version to compile down to. ES2022
// supports top-level await, class fields, .at().
// Modern enough for 2026 browsers. Use ES2020 if
// you must support older browsers.

// lib: ["ES2022", "DOM", "DOM.Iterable"]
// Which built-in types exist. DOM adds document,
// window, fetch. Add other libs for other envs.

// module: "ESNext"
// Output module syntax. ESNext keeps import/export.
// "CommonJS" would convert to require(). Use ESNext
// for modern bundlers.

// moduleResolution: "bundler"
// How to resolve imports. "bundler" (new in TS 5)
// matches how Vite, webpack, esbuild work. Use it
// for any modern bundler. "node" is for Node.js.

// esModuleInterop: true
// Lets you import CJS modules as if they were ESM.
// import express from 'express' works without this
// only if the library ships compatible types. Set it.

// resolveJsonModule: true
// Lets you import JSON files with proper types.
// import data from './data.json' works.

// isolatedModules: true
// Each file must be independently compilable.
// Required by esbuild, Vite, and Babel. Set it.

// noEmit: true
// Don't output .js files — just type check.
// Set when a bundler (Vite) handles the build.
// Set to false if you use plain tsc for output.

// skipLibCheck: true
// Skip type checking of .d.ts files in node_modules.
// Speeds up builds significantly. Some disagree with
// it, but for most projects the trade is worth it.

// paths: { "@/*": ["src/*"] }
// Path aliases. "@/" → "src/". Requires the same
// config in your bundler.

strict: true — non-negotiable

The single most important tsconfig option. Catches null/undefined, implicit any, and more. Enable from day one — retrofitting later is painful because it surfaces hundreds of errors.

target and lib

target = output JavaScript version. lib = which built-in types exist (DOM, ES2022). Choose target for the browser you support; choose lib for the APIs you use.

module and moduleResolution

module: ESNext keeps modern import syntax. moduleResolution: bundler matches Vite/webpack resolution rules. Use this pair for modern tooling.

noEmit: true

When a bundler handles compilation (Vite, Next.js), set noEmit — tsc only type checks. Output comes from the bundler. Standard in 2026.

Don't turn on strict after building 10,000 lines of non-strict code. The errors will overwhelm you. If you're starting a new project: strict: true from commit one. If you're retrofitting an existing project: enable it one file at a time with // @ts-strict-ignore or migrate to a strict: true tsconfig slowly. The cost of retrofitting is real — the cost of not having it is a class of bugs that ship to production. Pay it now or pay it forever.

📦 ESM vs CommonJS — the two module systems

Modern standard vs legacy Node. Here's the difference and what to use.

JavaScript has two module systems in active use: ESM (ECMAScript Modules, the standard) and CommonJS (the Node.js legacy system). TypeScript compiles to either. For new projects, use ESM. For maintaining old Node code, you'll encounter CommonJS. Knowing the difference saves hours of "Cannot use import statement outside a module" errors.

modules.ts
// ─── ESM syntax (what you write) ─────────────
import { useState } from 'react';
import express from 'express';
import type { User } from './types/user';

export function greet(name: string): string {
return Hello, ${name}`;
}

export const VERSION = '1.0.0';

// ─── Default export ──────────────────────────
export default function app() { return 'app'; }

// ─── Type-only imports ───────────────────────
// import type is erased at compile time.
// Use it when you only need types, not values.
// Prevents accidental runtime imports of unused code.

// ─── Namespace imports ──────────────────────
import * as React from 'react';
// With module: "ESNext" — output keeps import/export.
// This is what modern bundlers expect.

import { useState } from 'react';
import express from 'express';
// Type-only import erased entirely

export function greet(name) {
return Hello,${name}`;
}

export const VERSION = '1.0.0';

export default function app() { return 'app'; }

import * as React from 'react';

// ─────────────────────────────────────────────
// If module: "CommonJS" were set instead, the
// output would be:
//
// const { useState } = require('react');
// const express = require('express');
// exports.greet = function(name) {...};
// exports.VERSION = '1.0.0';
//
// Both work. ESM is the modern standard.

ESM — the modern standard

import / export, top-level await, tree-shakeable. What browsers support natively. What Vite bundles. What every library publishes. Use this for new code.

CommonJS — Node legacy

require() / module.exports, synchronous, no static analysis. Older Node code uses this. Some libraries ship only CJS. You can use both — but not always in the same file.

import type

Type-only imports are erased at compile time. Use when you import types without runtime values. Prevents accidental imports of unused code. Required with isolatedModules: true.

.mts and .cts

File extensions for "force ESM" and "force CommonJS" — override tsconfig's module setting per file. Rarely needed in 2026 but useful for edge cases.

Use ESM. Add CommonJS support only if a dependency forces you to. Every modern framework is ESM-first: Vite, Next.js, Astro, Remix, Deno, Bun. Node.js 20+ supports ESM natively. If you're starting a new project in 2026, module: "ESNext" and moduleResolution: "bundler" — that's it. CommonJS is only necessary when you're maintaining legacy Node servers or dealing with libraries that haven't updated. Don't reach for it by default.

🛤️ Path aliases — imports that survive refactoring

../../../types/user is a smell. @/types/user is a fix.

As projects grow, relative imports multiply: import { User } from '../../../../types/user'. Move a file one directory and every import breaks. Path aliases solve this: @/types/user always resolves from src/, no matter where the importing file lives. Requires config in both tsconfig (for the compiler) and your bundler (for the build).

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

// Now in any file, anywhere in src/:
// import { User } from '@/types/user';
// import { Button } from '@components/Button';
// import { format } from '@utils/format';

// BEFORE — fragile:
// import { User } from '../../../types/user';
// import { Button } from '../../../../components/Button';
// Move a file → every relative path breaks.

// AFTER — stable:
// import { User } from '@/types/user';
// import { Button } from '@components/Button';
// Move a file → paths still work.
// Vite config MUST mirror the tsconfig paths.
// TS config makes the type checker happy.
// Vite config makes the actual bundling work.
// Both are required — one without the other breaks.

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';

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

// ─────────────────────────────────────────────
// Webpack, Rollup, esbuild, Next.js — same idea,
// different config syntax. Every bundler supports
// aliases, but each has its own way of declaring them.
// If you use Next.js, tsconfig paths work out of the box
// (no bundler config needed). Vite requires both.

Why aliases

Relative imports scale badly. Move one file and every ../../../ breaks. Aliases keep imports stable — @/types/user works from any file, any depth.

Two configs required

tsconfig paths tells the type checker. Bundler alias tells the build. Both must match or imports break at compile or runtime. Copy-paste the same list in both.

baseUrl + paths

baseUrl is the root for resolution ("." = project root). paths maps patterns to locations. "@/*": ["./src/*"] means @/x resolves to src/x.

Conventions

@/ for src/ is the most common. Some projects use ~/ or module-specific prefixes like @components/. Pick a convention and enforce it with ESLint.

Set up path aliases on day one, not month three. Adding aliases to an existing codebase requires editing every import in every file — a painful mechanical refactor. Adding them at project start takes 5 minutes: two config files, one search-replace. Every project over 20 files benefits. Every project over 100 files needs them. This is the highest-ROI 5 minutes of TypeScript setup you'll ever spend.

📜 .d.ts files & @types — describing JavaScript

How TypeScript knows about libraries written in JavaScript.

A .d.ts file contains only type information — no runtime code. It's how TypeScript understands JavaScript libraries that don't ship types. When you npm install lodash, TypeScript looks for lodash.d.ts or a matching @types/lodash package. Without it, imports of that library are any — losing all safety.

declarations.ts
// ─── types/my-lib.d.ts ────────────────────────
// A .d.ts file declares the shape of a library.
// It has no implementation — only signatures.
// At compile time, TS uses it. At runtime, it's ignored.

declare module 'my-lib' {
export interface Config {
apiKey: string;
endpoint?: string;
}

export function initialize(config: Config): void;

export function query(sql: string, params?: unknown[]): Promise<unknown[]>;

export default {
initialize: typeof initialize;
query: typeof query;
};
}

// ─── Global declarations ─────────────────────
// Add types to global scope (window, document extensions):

declare global {
interface Window {
myAnalytics: {
track(event: string, props?: Record<string, unknown>): void;
};
}
}

export {};

// ─── Ambient module without a body ───────────
// If a module has no types and you just want to
// silence the error and use any (not recommended):

declare module 'untyped-lib';
// Now import x from 'untyped-lib' — x is any

// ─── Global type declarations ────────────────
declare type UUID = string;
declare const APP_VERSION: string;
// ─── Using a library with .d.ts types ─────────

import { initialize, query, Config } from 'my-lib';

// Full autocomplete + type checking — from the .d.ts file
initialize({
apiKey: 'abc123',
endpoint: 'https://api.example.com'
});

const result = await query('SELECT * FROM users');
// result: unknown[] (from the .d.ts return type)

// ─── How @types/* works ───────────────────────
// When you install:
// npm install lodash
// npm install --save-dev @types/lodash
//
// TypeScript looks in:
// node_modules/@types/lodash/index.d.ts
//
// It finds types automatically — no import needed.
// You just get them when you import lodash.

// ─── Do I need @types? ────────────────────────
// If the library ships its own .d.ts (most modern libs do):
// → no @types needed. Installing both causes conflicts.
//
// If the library is plain JavaScript (older libs):
// → install @types/libname. If it exists, great. If not,
// you might need to write your own .d.ts or use any.
//
// Popular @types packages:
// @types/node, @types/react, @types/express,
// @types/lodash, @types/jest, @types/lodash.debounce

Types only, no code

.d.ts files have no implementation. They declare signatures. The compiler uses them for checking; the runtime ignores them entirely. Zero bytes in the bundle.

declare module

Wrap declarations in declare module 'libname' { ... } to describe a module. Any import of that name will use these types.

declare global

Add types to global scope (extensions to window, document, built-ins). Requires export {} at the end to make the file a module.

@types/ auto-resolution

TS automatically finds node_modules/@types/. Install @types/node and you get types for Node APIs — no import, no config.

Never write declare module 'lib'; with no body to silence errors. This makes the entire module any — you lose autocomplete, type checking, everything. It's the same as turning TypeScript off for that library. If a library has no types, either find an @types/* package, write a proper .d.ts with real signatures, or use it via a guarded interface. The silent-any shortcut defeats the entire point of using TypeScript.

⚡ Vite + TypeScript — the modern setup

Three commands, full TypeScript, hot reload, production build.

Vite is the modern standard for frontend build tooling. It's what React, Vue, Svelte, and Solid recommend. For TypeScript: Vite compiles TS with esbuild (fast, ~10x faster than tsc) during dev, and bundles with Rollup for production. The one catch: Vite doesn't type check by default — you run tsc --noEmit separately. That's a feature: dev stays fast, and type checking runs in CI.

terminal
# ─── Scaffold a Vite + TS project ────────────
npm create vite@latest my-app -- --template react-ts
# Other templates: vanilla-ts, vue-ts, svelte-ts, solid-ts

cd my-app
npm install
npm run dev

 VITE v5.4.0 ready in 312 ms
 ➜ Local: http://localhost:5173/

# ─── Type check separately ───────────────────
# Vite uses esbuild — fast but skips type checking.
Add tsc --noEmit for the full check:

npx tsc --noEmit

# No output = no errors. Fast (uses incremental cache).
# This is what CI runs before building.

# ─── Production build ────────────────────────
npm run build
# Runs tsc --noEmit + vite build (Rollup bundle)

npm run preview
# Serves the built output locally
{
"name": "my-app",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"devDependencies": {
"typescript": "^5.6.0",
"vite": "^5.4.0",
"@vitejs/plugin-react": "^4.3.0"
}
}

// ─── The build script is the key ─────────────
// "tsc --noEmit && vite build"
//
// 1. tsc --noEmit — type check every file.
// Fails the build if any type error exists.
// 2. vite build — bundles TS → optimized JS.
// Uses esbuild, doesn't check types.
//
// This pattern: type check FIRST, then build.
// If types fail, you never ship a broken build.
// If types pass, Vite produces optimized output.
//
// CI runs npm run build — full safety, fast dev.
// ─── Typical Vite + TS project structure ────

my-app/
├── node_modules/
├── public/ // static assets
├── src/
│ ├── components/ // .tsx files
│ ├── hooks/ // use*.ts files
│ ├── types/ // shared types
│ ├── utils/ // helpers
│ ├── App.tsx // root component
│ ├── main.tsx // entry point
│ └── vite-env.d.ts // Vite type refs
├── index.html // single HTML entry
├── package.json
├── tsconfig.json // base config
├── tsconfig.node.json // config for vite.config.ts
└── vite.config.ts // Vite config (with aliases)

// ─── Two tsconfig files — why? ──────────────
// tsconfig.json → for src/ (browser code)
// tsconfig.node.json → for vite.config.ts (Node code)
//
// The two files have different targets and libs:
// src/ needs DOM; vite.config.ts needs Node.
// Vite scaffolds both automatically.

// ─── vite-env.d.ts — what's in it ───────────
// /// 
//
// This one line tells TS about Vite's env vars,
// asset imports (import logo from './logo.svg'),
// and other Vite-specific features.

esbuild during dev

Vite uses esbuild (written in Go) to compile TS — ~10x faster than tsc. That's why dev startup is instant even on large projects. The trade: esbuild doesn't type check.

tsc --noEmit separately

Run this to catch type errors. Vite doesn't do it (too slow for dev). In CI, run tsc --noEmit before vite build — the standard pattern.

Two tsconfig files

tsconfig.json for browser code, tsconfig.node.json for tooling config. Different targets (DOM vs Node), different types. Vite scaffolds both automatically.

vite-env.d.ts

One line: /// . Gives you types for Vite env vars, asset imports, and Vite-specific globals. Auto-generated.

Vite's "no type check in dev" is a feature, not a bug. Beginners complain that Vite lets type errors through in dev — but that's exactly what makes it fast. Your editor (VS Code) type checks on every keystroke (via the TS language server). Your CI type checks on every PR (tsc --noEmit). Vite's job is to compile and serve as fast as possible, not to re-verify what your editor already checked. If a type error slips through, you'll see it in the editor's red squiggles immediately. Trust the layered checks — don't try to make Vite do everything.

🏆 Challenge

Three tasks. Each builds a real piece of project infrastructure.

Task 1 — Configure a project from scratch

Create a fresh Vite + TS project. Then modify the tsconfig:

1. Scaffold with npm create vite@latest my-app -- --template vanilla-ts
2. Open tsconfig.json — read every option it ships with
3. Add a path alias: "@/*": ["./src/*"] under paths (create baseUrl: "." if missing)
4. Add the matching alias to vite.config.ts using path.resolve
5. Create src/types/user.ts with a User interface
6. In src/main.ts, import it via import type { User } from '@/types/user'
7. Verify the import works in the browser (dev server) and in npx tsc --noEmit

If the alias fails at runtime but works in tsc: your vite config is missing. If it fails in tsc but works at runtime: your tsconfig is missing. Both failures are instructive.

Task 2 — Write a .d.ts for an untyped library

Build type declarations for a library that ships without types:

1. Create src/types/my-lib.d.ts
2. Declare a module 'my-lib' with: an init(config: { key: string }) function, a fetchData(url: string): Promise function, and a default export with both
3. In src/main.ts, import from 'my-lib' (don't npm install it — the .d.ts makes it work)
4. Call init({ key: 'abc' }) and fetchData('/api/user')
5. Verify autocomplete works and the generic preserves the return type
6. Try calling init({ wrong: 1 }) — confirm the compile error

The point: writing .d.ts files is how you integrate any untyped codebase. Every JavaScript library you'll ever touch can get types this way.

Task 3 — Set up layered type checking

Build the CI-ready type checking pipeline:

1. In your Vite project, add to package.json scripts: "typecheck": "tsc --noEmit" and "ci": "npm run typecheck && npm run build"
2. Run npm run typecheck — confirm no errors
3. Deliberately introduce a type error (e.g. const x: number = 'string' in main.ts)
4. Run npm run dev — notice the browser still runs (Vite ignores types)
5. Run npm run typecheck — confirm it fails with a clear error
6. Run npm run ci — confirm the build fails at the typecheck step
7. Fix the error and confirm both pass

The point: Vite lets you develop quickly with type errors, but your CI gate catches them before shipping. This is the standard 2026 pipeline. Once you've seen the flow, you'll set it up on every project.

⚠️ Common mistakes

The tooling bugs that cost hours if you hit them cold.

1. Skipping strict: true

Default tsconfig is permissive — null and undefined sneak through. Fix: "strict": true from day one. Retrofitting later is painful.

2. Path alias without bundler config

Aliases work in tsc but fail at runtime because Vite/webpack doesn't know them. Fix: same aliases in vite.config.ts as tsconfig.paths.

3. Using module: "CommonJS" with Vite

Vite expects ESM. CommonJS output breaks tree-shaking and hot reload. Fix: module: "ESNext" and moduleResolution: "bundler" for Vite projects.

4. Installing @types/* for libraries that ship types

@types/react vs React 19's built-in types → conflicts, duplicated definitions. Fix: check if the library ships .d.ts first. If yes, no @types needed.

5. Using declare module 'lib'; with no body

Silences the error but makes the entire library any. Fix: write real types, or install @types/lib. The silent-any shortcut defeats TypeScript.

6. Expecting Vite to type check

Vite uses esbuild — no type checking. Fix: run tsc --noEmit separately, in CI, or in your build script. Vite is for speed, not type safety.

7. Mixing ESM and CJS in the same project

Some files use import, others use require. Breaks module resolution. Fix: pick one — ESM for new projects, CJS only for legacy Node.

8. Forgetting import type with isolatedModules

With isolatedModules: true (required by esbuild/Vite), types must be imported with import type. Fix: use import type { User } for type-only imports.

❓ Frequently asked questions

What is tsconfig.json?;

tsconfig.json is the configuration file for the TypeScript compiler. It tells tsc which files to compile, what JavaScript version to target, which module system to output, how strict to be, and dozens of other settings. Every TypeScript project has one at the root. Vite, Next.js, and other tools read it automatically.

What does the strict flag do?

strict: true turns on eight type-checking options at once, including strictNullChecks, noImplicitAny, strictFunctionTypes, and strictPropertyInitialization. It's the single most important tsconfig option — it catches the bugs that matter. Always enable it from day one.

What is the difference between ESM and CommonJS?

ESM (ECMAScript Modules) is the modern standard: import / export syntax, top-level await, tree-shakeable. CommonJS is the older Node.js system: require() / module.exports, synchronous loading. For new projects, use ESM. CommonJS is for maintaining older Node code.

What is a .d.ts file?

A .d.ts file is a TypeScript declaration file — it contains only type information, no runtime code. It's how you tell TypeScript about JavaScript code that has no built-in types. When you npm install lodash, it ships with lodash.d.ts (or you install @types/lodash) that describes lodash's API.

What are @types packages?

@types packages are community-maintained type definitions for JavaScript libraries that don't ship their own types. They live under the @types scope on npm: @types/node, @types/react, @types/lodash. If a library ships its own types, you don't need @types — installing both causes conflicts.

What are path aliases in TypeScript?

Path aliases let you import using short names instead of relative paths. Instead of import { User } from '../../../types/user', you write import { User } from '@/types/user'. Configure them in tsconfig.json under compilerOptions.paths, and your bundler needs the same aliases in its config.

How do I set up TypeScript with Vite?

Run npm create vite@latest my-app -- --template react-ts (or vanilla-ts). Vite scaffolds the project with tsconfig.json, package.json, and a src/ folder. Vite uses esbuild to compile TS during dev and Rollup for production builds. For type checking, run tsc --noEmit separately.

What is the difference between tsc and a bundler?

tsc is the TypeScript compiler — it checks types and outputs JavaScript. A bundler (Vite, webpack, Rollup, esbuild) combines many modules into one file for browsers, handles CSS/assets, and does optimizations. Modern setups use both: a bundler for production builds, and tsc --noEmit for type checking.

🔗 Continue your TypeScript path

🌐 External resources

🔧 Related problem solvers

← Back to TypeScript track
You're in the TypeScript track · Lesson 7