Your project has 200 files. Types are scattered across them. Some are imported. Some are global. Some are inferred. Some are declared but never defined. You change one interface and fifteen files break — not because the change was wrong, but because you don't understand how types flow through your project.
This chapter is about boundaries. How to define them. How to enforce them. How to make types flow cleanly between files without creating a tangled web of dependencies. By the end, you'll know how to structure a TypeScript project that scales from 10 files to 10,000.
ES Modules and TypeScript
TypeScript uses the same module system as modern JavaScript: ES modules. import and export work exactly as you'd expect:
// user.ts
export interface User {
name: string;
email: string;
}
export function createUser(name: string, email: string): User {
return { name, email };
}
// app.ts
import { User, createUser } from "./user";
const user: User = createUser("Alice", "alice@example.com");
TypeScript adds one thing: type-only imports and exports.
Type-Only Imports
import type { User } from "./user";
This tells TypeScript (and your bundler): "I only need this import for type checking. Erase it from the compiled JavaScript." The import is removed during compilation, so it has zero runtime cost.
Use import type when you're importing something you only use as a type:
import type { User } from "./user";
import { createUser } from "./user"; // This stays in the JS output
const user: User = createUser("Alice", "alice@example.com");
Type-Only Exports
export type { User, CreateUserFn } from "./user";
Same idea — the export is erased from the compiled output.
Inline Type Imports (TypeScript 7)
TypeScript 7 lets you mark individual imports as type-only inline:
import { type User, createUser } from "./user";
// User is erased, createUser stays
This is cleaner than separate import type lines when you need both types and values from the same module.
Declaration Files (.d.ts)
Declaration files are the bridge between TypeScript and JavaScript. They describe the types of JavaScript code without changing the code itself.
What a Declaration File Looks Like
// user.d.ts
export interface User {
name: string;
email: string;
age: number;
}
export function createUser(name: string, email: string, age: number): User;
export function getUserById(id: number): User | undefined;
export function getAllUsers(): User[];
A .d.ts file contains ONLY type declarations — no implementation. It's a contract that says "this JavaScript module has these types."
When to Write Declaration Files
-
You're publishing an npm package written in TypeScript. Set
declaration: trueintsconfig.jsonand TypeScript generates.d.tsfiles automatically. -
You're using a JavaScript library without types. Write a
.d.tsfile to describe its API. Or better, check if@types/that-libraryalready exists. -
You have global types that should be available everywhere (rare — prefer explicit imports).
Ambient Declarations
Ambient declarations describe types that exist in the global scope:
// globals.d.ts
declare const API_URL: string;
declare function trackEvent(event: string, data: Record<string, unknown>): void;
declare namespace App {
const version: string;
const environment: "development" | "production";
}
Use declare to tell TypeScript about things that exist at runtime but aren't defined in TypeScript code — like global variables injected by your build tool, or scripts loaded via <script> tags.
Module Augmentation
You can add types to existing modules:
// express.d.ts
import "express";
declare module "express" {
interface Request {
user?: {
id: number;
name: string;
};
}
}
Now every Request object in your Express app has a user property. This is how middleware types work — each middleware augments the Request type with its own additions.
Global Augmentation
You can add types to the global scope:
// window.d.ts
declare global {
interface Window {
analytics: {
track: (event: string, data: Record<string, unknown>) => void;
};
}
}
Now window.analytics is typed everywhere in your project.
Module Resolution
Module resolution is how TypeScript finds the file you're importing. Understanding it prevents "Cannot find module" errors.
The Resolution Strategies
| Strategy | How It Works | Use When |
|---|---|---|
bundler | Simulates how bundlers (Vite, Webpack) resolve imports | Frontend apps with a bundler |
NodeNext | Uses Node.js's native ESM resolution | Node.js apps without a bundler |
node (legacy) | Uses old Node.js CommonJS resolution | Legacy projects |
classic (legacy) | Simple relative resolution | Almost never |
For new projects:
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler"
}
}
This is the modern default. It supports extensionless imports, package.json exports, and all the patterns modern bundlers use.
Path Aliases
Long relative imports are a code smell:
import { User } from "../../../shared/types/user";
Path aliases fix this:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@shared/*": ["src/shared/*"],
"@components/*": ["src/components/*"]
}
}
}
Now you can write:
import { User } from "@shared/types/user";
import { Button } from "@components/Button";
Your bundler needs matching aliases. In Vite:
// vite.config.ts
export default defineConfig({
resolve: {
alias: {
"@": "/src",
"@shared": "/src/shared",
"@components": "/src/components",
},
},
});
Project References
For large projects (monorepos, multiple packages), project references let you split your TypeScript configuration:
// tsconfig.json (root)
{
"references": [
{ "path": "./packages/shared" },
{ "path": "./packages/server" },
{ "path": "./packages/client" }
]
}
// packages/shared/tsconfig.json
{
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src"
}
}
Project references enable:
- Incremental builds: Only rebuild changed packages
- Type checking across packages: Types flow between referenced projects
- IDE performance: Your editor only loads the projects you're working on
Organizing Types in a Large Project
After years of TypeScript, here's the structure I've settled on:
src/
├── types/
│ ├── domain/ # Core business types (User, Product, Order)
│ │ ├── user.ts
│ │ ├── product.ts
│ │ └── order.ts
│ ├── api/ # API request/response types
│ │ ├── requests.ts
│ │ └── responses.ts
│ ├── shared/ # Utility types used everywhere
│ │ ├── result.ts # Result<T, E> type
│ │ └── async.ts # AsyncState<T> type
│ └── index.ts # Re-exports everything
├── features/
│ ├── auth/
│ │ ├── auth.service.ts
│ │ ├── auth.types.ts # Types specific to this feature
│ │ └── auth.handlers.ts
│ └── dashboard/
│ ├── dashboard.service.ts
│ └── dashboard.types.ts
└── shared/
├── utils/
└── components/
The Rules
-
Domain types go in
types/domain/. These are the core types that represent your business entities. They're imported everywhere. -
Feature-specific types stay in the feature. A type only used by the auth module doesn't need to be in the global types directory.
-
Utility types go in
types/shared/.Result<T, E>,AsyncState<T>,Nullable<T>— types that are used across features. -
API types mirror your API. Request and response types should match what your API actually sends and receives.
-
Avoid circular dependencies. If
user.tsimports fromproduct.tsandproduct.tsimports fromuser.ts, you have a design problem.
Publishing TypeScript Packages
When you publish an npm package written in TypeScript:
-
Compile to JavaScript. Your package should ship
.jsfiles, not.tsfiles. -
Generate declaration files. Set
declaration: trueso consumers get type information. -
Set up
package.jsoncorrectly:
{
"name": "my-package",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}
- Don't publish your
tsconfig.json. It's a build tool, not part of your public API.
Try This: Modules and Declaration Files
- Create a project with two files:
user.ts(exportsUserinterface andcreateUserfunction) andapp.ts(imports and uses them). - Add a
globals.d.tsthat declares a globalAPI_URLconstant. Use it in your code. - Create a path alias
@types/*that points to your types directory. Use it in an import. - Write a module augmentation that adds a
userproperty to an existing interface. - Set up a
tsconfig.jsonwithdeclaration: trueand examine the generated.d.tsfiles.
Time needed: 20 minutes.
What to notice: How import type eliminates runtime imports. How declaration files describe JavaScript code without changing it. How path aliases make imports cleaner. How module augmentation lets you extend third-party types.
The Question
You now know how to structure TypeScript projects. You understand modules, declaration files, and how types flow between files. You've mastered the type system from primitives to type-level programming.
But everything you've learned so far is TypeScript as it's been for years. The next four chapters are about what's NEW. TypeScript 7 features that change how you write code. Features the community has been waiting for.
The first one is pattern matching — the most requested TypeScript feature of all time.
In the next chapter: TypeScript 7 pattern matching — exhaustive matching on types and values, and why this single feature changes how you handle discriminated unions, API responses, and every conditional in your codebase.