Skip to main content

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​

  1. You're publishing an npm package written in TypeScript. Set declaration: true in tsconfig.json and TypeScript generates .d.ts files automatically.

  2. You're using a JavaScript library without types. Write a .d.ts file to describe its API. Or better, check if @types/that-library already exists.

  3. 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​

StrategyHow It WorksUse When
bundlerSimulates how bundlers (Vite, Webpack) resolve importsFrontend apps with a bundler
NodeNextUses Node.js's native ESM resolutionNode.js apps without a bundler
node (legacy)Uses old Node.js CommonJS resolutionLegacy projects
classic (legacy)Simple relative resolutionAlmost 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​

  1. Domain types go in types/domain/. These are the core types that represent your business entities. They're imported everywhere.

  2. 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.

  3. Utility types go in types/shared/. Result<T, E>, AsyncState<T>, Nullable<T> — types that are used across features.

  4. API types mirror your API. Request and response types should match what your API actually sends and receives.

  5. Avoid circular dependencies. If user.ts imports from product.ts and product.ts imports from user.ts, you have a design problem.

Publishing TypeScript Packages​

When you publish an npm package written in TypeScript:

  1. Compile to JavaScript. Your package should ship .js files, not .ts files.

  2. Generate declaration files. Set declaration: true so consumers get type information.

  3. Set up package.json correctly:

{
"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"
}
}
}
  1. Don't publish your tsconfig.json. It's a build tool, not part of your public API.

Try This: Modules and Declaration Files​

  1. Create a project with two files: user.ts (exports User interface and createUser function) and app.ts (imports and uses them).
  2. Add a globals.d.ts that declares a global API_URL constant. Use it in your code.
  3. Create a path alias @types/* that points to your types directory. Use it in an import.
  4. Write a module augmentation that adds a user property to an existing interface.
  5. Set up a tsconfig.json with declaration: true and examine the generated .d.ts files.

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.