Skip to main content
Back to Blog
TypeScriptReactNext.jsFrontend ArchitectureDesign PatternsScalabilityType SafetyAdvanced TypeScript

Advanced TypeScript Patterns: Scalable Frontend Architectures for Large React/Next.js Codebases

Master type-safe patterns and modular architectures in TypeScript that scale across large React and Next.js codebases — the techniques that signal readiness for senior and frontend lead positions.

April 6, 202613 min readNiraj Kumar

Introduction

If you've been writing React and Next.js applications for a while, you know that the real challenge isn't getting things to work — it's keeping them maintainable, scalable, and type-safe as the codebase grows from thousands to hundreds of thousands of lines.

Senior engineers and frontend leads aren't just people who know more syntax. They're people who make architectural decisions that prevent entire categories of bugs, keep teams productive, and allow codebases to evolve without becoming a tangled mess.

In this article, we'll explore advanced TypeScript patterns that are genuinely used in large-scale production codebases in 2026. These aren't academic exercises — they're the kind of patterns that appear in code reviews at top engineering teams and that interviewers probe for when evaluating senior candidates.

Whether you're an intermediate developer looking to level up, or a senior looking to formalize what you already intuitively do, this guide will give you concrete, battle-tested tools.


1. Discriminated Unions: Modeling State Like a Pro

One of the most powerful patterns in TypeScript is the discriminated union — a union type where each member has a literal type field (a "discriminant") that narrows the type in conditionals.

Why This Matters

Without discriminated unions, async state typically looks like this:

// ❌ The naive approach — full of pain
interface FetchState {
  isLoading: boolean;
  data: User[] | null;
  error: Error | null;
}

This representation allows impossible states: isLoading: true AND data: [...] at the same time? TypeScript won't stop you.

The Discriminated Union Solution

// ✅ Exhaustive, impossible-state-free modeling
type FetchState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

// Usage in a component
function UserList() {
  const [state, setState] = React.useState<FetchState<User[]>>({ status: "idle" });

  if (state.status === "loading") return <Spinner />;
  if (state.status === "error") return <ErrorBanner message={state.error.message} />;
  if (state.status === "success") return <List items={state.data} />;
  return <EmptyState />;
}

TypeScript now knows exactly what properties are available in each branch. No more data! or data as User[] — the type system does the work.

Exhaustive Checks with never

When you switch on a discriminant, you can enforce exhaustive handling:

function assertNever(value: never): never {
  throw new Error(`Unhandled discriminant: ${JSON.stringify(value)}`);
}

function renderState(state: FetchState<User[]>) {
  switch (state.status) {
    case "idle": return <IdleView />;
    case "loading": return <Spinner />;
    case "success": return <UserGrid users={state.data} />;
    case "error": return <ErrorView error={state.error} />;
    default: return assertNever(state);
  }
}

If you add a new status variant later, TypeScript will surface a compile-time error at the assertNever call — you can't forget to handle new cases.


2. The Builder Pattern for Complex Configuration Objects

Large applications inevitably accumulate complex configuration objects — for forms, queries, API clients, report generators. The Builder pattern keeps this manageable.

Without the Builder Pattern

// ❌ Constructor hell — easy to get argument order wrong
const query = new DataQuery(
  "users",
  ["id", "name", "email"],
  { role: "admin" },
  10,
  0,
  "createdAt",
  "desc",
  true
);

Nobody reading this knows what true means on the last argument.

With a Fluent Builder

// ✅ Self-documenting, composable, and type-safe
class QueryBuilder<T extends Record<string, unknown>> {
  private config: {
    table: string;
    fields: (keyof T)[];
    filters: Partial<T>;
    limit: number;
    offset: number;
    orderBy?: keyof T;
    orderDir: "asc" | "desc";
    includeDeleted: boolean;
  };

  constructor(table: string) {
    this.config = {
      table,
      fields: [],
      filters: {},
      limit: 20,
      offset: 0,
      orderDir: "asc",
      includeDeleted: false,
    };
  }

  select(...fields: (keyof T)[]): this {
    this.config.fields = fields;
    return this;
  }

  where(filters: Partial<T>): this {
    this.config.filters = { ...this.config.filters, ...filters };
    return this;
  }

  limit(n: number): this {
    this.config.limit = n;
    return this;
  }

  offset(n: number): this {
    this.config.offset = n;
    return this;
  }

  orderBy(field: keyof T, dir: "asc" | "desc" = "asc"): this {
    this.config.orderBy = field;
    this.config.orderDir = dir;
    return this;
  }

  withDeleted(): this {
    this.config.includeDeleted = true;
    return this;
  }

  build() {
    return { ...this.config };
  }
}

// Usage — reads like English
const query = new QueryBuilder<User>("users")
  .select("id", "name", "email")
  .where({ role: "admin" })
  .limit(10)
  .orderBy("createdAt", "desc")
  .build();

The generic T ensures that .select() and .where() only accept real keys of your type, and .build() returns a fully typed result.


3. The Repository Pattern for Data Access

Large React/Next.js apps often scatter API calls across components and hooks. The Repository pattern centralizes data access behind a typed interface, making it mockable, testable, and swappable.

Define an Interface First

// repository/types.ts
export interface UserRepository {
  getById(id: string): Promise<User>;
  getAll(options?: PaginationOptions): Promise<PaginatedResult<User>>;
  create(payload: CreateUserPayload): Promise<User>;
  update(id: string, payload: UpdateUserPayload): Promise<User>;
  delete(id: string): Promise<void>;
}

Implement for Production

// repository/user.repository.ts
export class HttpUserRepository implements UserRepository {
  constructor(private readonly baseUrl: string) {}

  async getById(id: string): Promise<User> {
    const res = await fetch(`${this.baseUrl}/users/${id}`);
    if (!res.ok) throw new ApiError(res.status, await res.text());
    return res.json() as Promise<User>;
  }

  async getAll(options?: PaginationOptions): Promise<PaginatedResult<User>> {
    const params = new URLSearchParams({
      page: String(options?.page ?? 1),
      limit: String(options?.limit ?? 20),
    });
    const res = await fetch(`${this.baseUrl}/users?${params}`);
    if (!res.ok) throw new ApiError(res.status, await res.text());
    return res.json() as Promise<PaginatedResult<User>>;
  }

  // ... other methods
}

Implement for Testing

// repository/user.repository.mock.ts
export class MockUserRepository implements UserRepository {
  private users: User[] = [];

  async getById(id: string): Promise<User> {
    const user = this.users.find((u) => u.id === id);
    if (!user) throw new Error(`User ${id} not found`);
    return user;
  }

  async getAll(): Promise<PaginatedResult<User>> {
    return { data: this.users, total: this.users.length, page: 1, limit: 20 };
  }

  // Seed helper for tests
  seed(users: User[]) {
    this.users = users;
    return this;
  }

  // ... other methods
}

Consume via Dependency Injection

// hooks/useUsers.ts
function useUsers(repo: UserRepository) {
  return useQuery({
    queryKey: ["users"],
    queryFn: () => repo.getAll(),
  });
}

// In your app (production)
const userRepo = new HttpUserRepository(process.env.NEXT_PUBLIC_API_URL!);
<UserList repo={userRepo} />

// In your tests
const mockRepo = new MockUserRepository().seed(testUsers);
render(<UserList repo={mockRepo} />);

Your components now have zero knowledge of where data comes from — swapping from REST to GraphQL or tRPC is a one-file change.


4. Generic Utility Types for Reusable Contracts

TypeScript ships with utility types like Partial, Required, Omit, and Pick. Senior engineers know how to compose and extend them to model domain-specific constraints.

DeepReadonly for Immutable State

type DeepReadonly<T> = T extends (infer U)[]
  ? ReadonlyArray<DeepReadonly<U>>
  : T extends object
  ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
  : T;

// Entire Redux store is now deeply immutable
type AppState = DeepReadonly<{
  user: UserState;
  cart: CartState;
  ui: UIState;
}>;

NonNullableDeep for Validated Data

// After form validation, we know all fields are present
type NonNullableDeep<T> = {
  [K in keyof T]-?: NonNullableDeep<NonNullable<T[K]>>;
};

type RawFormData = {
  name?: string;
  email?: string | null;
  address?: { street?: string; city?: string };
};

type ValidatedFormData = NonNullableDeep<RawFormData>;
// { name: string; email: string; address: { street: string; city: string } }

Branded Types for Domain Safety

// Prevent mixing up semantically different string IDs
declare const __brand: unique symbol;
type Brand<T, TBrand> = T & { [__brand]: TBrand };

type UserId = Brand<string, "UserId">;
type ProductId = Brand<string, "ProductId">;
type OrderId = Brand<string, "OrderId">;

function createUserId(raw: string): UserId {
  return raw as UserId;
}

// Now passing a ProductId where a UserId is expected is a compile error
async function fetchUser(id: UserId): Promise<User> { /* ... */ }

const productId = createProductId("prod_123");
fetchUser(productId); // ❌ TypeScript error — caught at compile time, not runtime

Branded types are one of the clearest signals of mature TypeScript thinking in a codebase.


5. Module Federation and Feature-Sliced Design

As teams grow past 10-15 engineers, monolithic src/ folder structures become coordination bottlenecks. Feature-Sliced Design (FSD) is a widely-adopted architectural methodology that scales with team size.

FSD Folder Structure

src/
├── app/                  # App-level setup: providers, routing, global styles
│   ├── providers/
│   ├── styles/
│   └── store.ts
├── pages/                # Route-level compositions (Next.js pages or app dir)
│   ├── dashboard/
│   └── settings/
├── widgets/              # Composite UI blocks (assembled from features + entities)
│   ├── header/
│   └── sidebar/
├── features/             # User-facing features (business logic + UI)
│   ├── auth/
│   │   ├── ui/
│   │   ├── model/
│   │   └── api/
│   └── cart/
├── entities/             # Business domain models (pure data + basic UI)
│   ├── user/
│   └── product/
└── shared/               # Reusable infra: UI kit, utils, API client, types
    ├── ui/
    ├── lib/
    ├── api/
    └── types/

The Key Rule: Unidirectional Dependency

FSD enforces that layers can only import from layers below them:

app → pages → widgets → features → entities → shared

A feature can import from entities and shared, but never from widgets or pages. This single rule eliminates circular dependencies and makes features genuinely portable.

Enforcing This with ESLint

// .eslintrc.cjs
module.exports = {
  plugins: ["@feature-sliced"],
  rules: {
    "@feature-sliced/layers-slices": "error",
  },
};

6. Type-Safe Event Systems with Mitt

Component communication through props works fine in small apps. As apps grow, you need event buses — but naively, these are stringly-typed and fragile.

// events/types.ts
export type AppEvents = {
  "user:logged-in": { userId: string; role: UserRole };
  "user:logged-out": undefined;
  "cart:item-added": { productId: ProductId; quantity: number };
  "cart:cleared": undefined;
  "notification:show": { message: string; type: "success" | "error" | "info" };
};
// events/emitter.ts
import mitt from "mitt";
import type { AppEvents } from "./types";

export const emitter = mitt<AppEvents>();
// Usage in an auth feature
emitter.emit("user:logged-in", { userId: "u_123", role: "admin" });

// Usage in a notification feature
emitter.on("user:logged-in", ({ userId, role }) => {
  // `userId` and `role` are fully typed — no casting needed
  emitter.emit("notification:show", {
    message: `Welcome back, ${role}!`,
    type: "success",
  });
});

Every event name is a key in AppEvents. TypeScript will error if you try to emit an event that doesn't exist or pass the wrong payload shape.


7. Compound Components with Context

The Compound Component pattern lets you build expressive, flexible component APIs that feel native to JSX, without prop-drilling.

// components/Tabs/index.tsx
interface TabsContextValue {
  activeTab: string;
  setActiveTab: (tab: string) => void;
}

const TabsContext = React.createContext<TabsContextValue | null>(null);

function useTabs() {
  const ctx = React.useContext(TabsContext);
  if (!ctx) throw new Error("useTabs must be used within <Tabs>");
  return ctx;
}

function Tabs({
  defaultTab,
  children,
}: {
  defaultTab: string;
  children: React.ReactNode;
}) {
  const [activeTab, setActiveTab] = React.useState(defaultTab);
  return (
    <TabsContext.Provider value={{ activeTab, setActiveTab }}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  );
}

function TabList({ children }: { children: React.ReactNode }) {
  return <div role="tablist" className="tab-list">{children}</div>;
}

function Tab({ id, children }: { id: string; children: React.ReactNode }) {
  const { activeTab, setActiveTab } = useTabs();
  return (
    <button
      role="tab"
      aria-selected={activeTab === id}
      onClick={() => setActiveTab(id)}
    >
      {children}
    </button>
  );
}

function TabPanel({ id, children }: { id: string; children: React.ReactNode }) {
  const { activeTab } = useTabs();
  if (activeTab !== id) return null;
  return <div role="tabpanel">{children}</div>;
}

// Attach sub-components to root for ergonomic usage
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;

export { Tabs };
// Consumer — reads naturally, no configuration prop explosion
<Tabs defaultTab="profile">
  <Tabs.List>
    <Tabs.Tab id="profile">Profile</Tabs.Tab>
    <Tabs.Tab id="billing">Billing</Tabs.Tab>
    <Tabs.Tab id="security">Security</Tabs.Tab>
  </Tabs.List>
  <Tabs.Panel id="profile"><ProfileForm /></Tabs.Panel>
  <Tabs.Panel id="billing"><BillingInfo /></Tabs.Panel>
  <Tabs.Panel id="security"><SecuritySettings /></Tabs.Panel>
</Tabs>

Common Mistakes to Avoid

  • Overusing any and as: Every as SomeType cast is a promise to the compiler you might break. Prefer type narrowing, discriminated unions, or proper generics.

  • Putting business logic in components: Components should orchestrate UI. Domain logic (validation, transformations, calculations) belongs in pure functions or domain models that TypeScript can test independently.

  • Ignoring strict: true: Many teams set up TypeScript without "strict": true in tsconfig.json. This disables noImplicitAny, strictNullChecks, and other critical checks. Always ship with strict mode.

  • Barrel file abuse: Re-exporting everything from an index.ts barrel feels clean but causes circular imports and hampers tree-shaking in large codebases. Export deliberately.

  • Mixing layers in FSD: Importing a widget inside a feature breaks the unidirectional dependency rule and makes features impossible to reuse or test in isolation.

  • Not leveraging satisfies: The satisfies operator (available since TypeScript 4.9) lets you validate against a type without widening it — more precise than as const and safer than a type annotation alone.

// ❌ Type annotation widens the type
const config: Record<string, string> = { apiUrl: "https://..." };
// config.apiUrl is now `string`, not the literal value

// ✅ satisfies validates without widening
const config = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
} satisfies Record<string, string | number>;
// config.apiUrl is still `"https://api.example.com"` — literal type preserved

🚀 Pro Tips

  • Use infer for complex type extraction: When working with third-party libraries that don't export their internal types, infer lets you extract them: type Resolved<T> = T extends Promise<infer U> ? U : T.

  • Colocate types with their consumers: Don't create a god types.ts file. Types for a feature live inside that feature's folder. Only truly shared, domain-agnostic types belong in shared/types.

  • Make invalid state unrepresentable: Every time you reach for an optional property (field?: string), ask yourself if a discriminated union would be more honest about what combinations are actually valid.

  • Use zod at boundaries, TypeScript in the core: Validate and parse external data (API responses, form inputs, env vars) with zod at the entry point. Once data is inside your system, trust TypeScript's static types and don't re-validate.

  • Automate architecture rules: ESLint plugins (@feature-sliced/eslint-plugin, eslint-plugin-import) can enforce layer boundaries and import rules in CI — architecture as code, not as convention.

  • Write TypeScript, not "typed JavaScript": If your generics feel like noise you're fighting, step back and reconsider the design. Good TypeScript makes types disappear — they're inferred, not declared everywhere.

  • Document with @example and @link JSDoc: TypeScript types describe what a function accepts. JSDoc comments describe why and how. Both together give future teammates (and your future self) everything needed to work confidently with complex APIs.


📌 Key Takeaways

  • Discriminated unions eliminate impossible states and enable exhaustive, compiler-verified handling of every case.

  • The Builder pattern makes complex configuration objects self-documenting and type-safe without constructor argument chaos.

  • The Repository pattern decouples your components from data sources, making the app testable and adaptable to infrastructure changes.

  • Branded types enforce semantic correctness across domain boundaries — a UserId can never be accidentally used as a ProductId.

  • Feature-Sliced Design provides a team-scalable folder structure with unidirectional dependencies that prevent the circular import and coupling problems that plague growing codebases.

  • Compound components with context produce expressive, composable component APIs that avoid prop-drilling without sacrificing type safety.

  • Architecture is enforced, not just documented: Use ESLint, TypeScript strict mode, and tooling to make bad patterns fail at build time, not code review time.


Conclusion

The gap between a mid-level and senior frontend engineer isn't usually measured in component count or framework knowledge — it's measured in architectural judgment. Can you design a data model that makes bugs impossible? Can you structure a codebase so that ten engineers can work in parallel without stepping on each other? Can you make a TypeScript type do the work that would otherwise require a runtime check or a comment that nobody reads?

The patterns in this article — discriminated unions, the Builder, Repository, branded types, FSD, type-safe events, and compound components — are all tools toward that end. They're not clever tricks. They're solutions to real problems that appear at real scale.

Start with the pattern that addresses your biggest current pain point. Introduce it in a new feature rather than refactoring existing code. Let the team feel the benefit before expanding it. Architecture evolves best incrementally, with buy-in, not in a grand rewrite.

The TypeScript you write today is the foundation your team builds on tomorrow. Make it solid.

All Articles
TypeScriptReactNext.jsFrontend ArchitectureDesign PatternsScalabilityType SafetyAdvanced TypeScript

Written by

Niraj Kumar

Software Developer — building scalable systems for businesses.