Skip to Content
Course content

117: Structuring a Large TypeScript Codebase

Click on the "Edit" button in the top corner of the screen to edit your slide content.

When developers first move from small projects to enterprise-scale TypeScript codebases, they usually hit a wall with organization. I see it all the time: a developer decides to "be clean" by creating a single folder called /types or a massive file named types.ts where every single interface and type alias in the entire application lives.

The "Centralized Types" Trap

It feels intuitive. You think, "I'll put all my shapes in one place so I can find them easily." But in a large codebase, this is a recipe for disaster. Let's look at a hypothetical E-commerce system. You have User, Order, Product, and Payment types. If they all live in one types.ts file, that file becomes a "god object" that every single other file in your system must import.

// The problematic types.ts
export interface User { id: string; name: string; }
export interface Order { id: string; userId: string; items: OrderItem[]; }
export interface OrderItem { productId: string; quantity: number; }
export interface Product { id: string; price: number; }
export interface Payment { id: string; orderId: string; status: 'paid' | 'pending'; }
// ... 500 more lines of types

The moment you do this, you've created a massive bottleneck. If you change one property in Payment, TypeScript may have to re-evaluate every single file that imports types.ts, even the files that only care about User. Worse, you'll eventually run into circular dependency hell when your types start referencing each other across different domain boundaries, and your IDE's "Go to Definition" becomes a trip through a wasteland of unrelated interfaces.

Colocating Types with Domain Logic

The correct approach is Colocation. I want you to stop thinking about "types" as a category of code and start thinking about "features" as the category. If you are building an Order processing system, the types for an Order belong in the Order folder, right next to the functions that process them.

Instead of a global folder, structure your code by domain:

  • src/features/orders/orders.types.ts
  • src/features/orders/orders.service.ts
  • src/features/products/products.types.ts
  • src/features/products/products.api.ts

Now, when I'm working on the Order service, I only import the types I actually need. My dependency graph stays lean, and if I decide to delete the "Orders" feature entirely, I can just delete that one folder without hunting through a global types.ts file to scrub out orphaned interfaces. It's much more satisfying to delete a folder than to surgically remove lines from a 2,000-line file.

Defining Boundaries with Barrel Files

You might worry that this leads to "import sprawl"—having ten different import lines at the top of your file. This is where Barrel Files (index.ts) come in, but you have to use them with discipline. I use them to define a "Public API" for each feature.

Inside src/features/orders/index.ts, I only export what the rest of the app is allowed to see:

// src/features/orders/index.ts
export { Order, OrderStatus } from './orders.types';
export { createOrder, getOrderById } from './orders.service';
// Note: I am NOT exporting internal helper functions or private types here.

When the Payment feature needs to know about an Order, it imports from the feature folder, not the specific file: import { Order } from '@/features/orders'. This creates a clean boundary. I can change the internal structure of the orders folder—split one file into three, rename a private type—and as long as the index.ts remains the same, I haven't broken a single other part of the application.




📋 Practical Task

Refactoring the Monolithic Shopping Cart Structure

You've inherited a codebase where the previous developer put everything into two giant files: src/types.ts and src/api.ts. Your task is to refactor this into a feature-based structure to improve maintainability and build performance.

Current State:

// src/types.ts
export interface Cart { id: string; items: CartItem[]; }
export interface CartItem { productId: string; qty: number; }
export interface User { id: string; email: string; }
export interface UserProfile { bio: string; avatarUrl: string; }

// src/api.ts
export async function updateCart(cart: Cart) { /* ... */ }
export async function getUser(id: string): Promise<User> { /* ... */ }
export async function updateProfile(profile: UserProfile) { /* ... */ }

Your Goal:

  1. Create two feature folders: src/features/cart and src/features/user.
  2. Move the related types and functions into their respective folders (e.g., cart.types.ts and cart.service.ts).
  3. Implement a barrel file (index.ts) for each feature that exports only the necessary types and functions.
  4. Verify that you can import Cart and updateCart from @/features/cart without importing anything related to the User domain.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.