Back

How to Organize Type Definitions in a TypeScript Project

How to Organize Type Definitions in a TypeScript Project

Most TypeScript projects start the same way: a single types.ts file that slowly balloons to hundreds of lines. Finding anything becomes a chore. Reusing types across files feels risky. New team members don’t know where to look.

The good news is that organizing TypeScript types doesn’t require a complex system. It requires one clear rule: place types as close to where they’re used as possible, and only move them when they’re needed elsewhere.

Here’s how to apply that rule at every stage of a project.

Key Takeaways

  • Place types as close to their usage as possible and promote them to shared locations only when multiple files need them.
  • Use .ts files for explicit type exports and imports; reserve .d.ts files strictly for ambient declarations like environment variables or global augmentations.
  • Adopt a consistent naming convention such as *.types.ts for module-specific types and barrel index.ts files for clean, stable import paths.
  • In monorepos or shared packages, expose types through the types condition in package.json exports and keep internal types private.

The Core Decision: Where Should a Type Live?

Ask yourself one question: how many files need this type?

  • One file → define it inline in that file
  • A few files in the same module → colocate it in a shared .types.ts file nearby
  • Across the whole project → move it to a shared src/types/ directory
  • Across packages → publish it from a dedicated types package

This progression keeps your project’s type definitions organized without over-engineering from the start.

Pattern 1: Inline Types for Local Use

If a type is only used in one file, define it there. No separate file needed.

// UserCard.tsx
interface UserCardProps {
  name: string
  avatarUrl: string
  role: "admin" | "viewer"
}

export function UserCard({ name, avatarUrl, role }: UserCardProps) {
  // ...
}

This is the right default. Premature abstraction creates indirection without benefit.

Pattern 2: Colocated Type Files for Shared Module Types

When two or more components in the same folder share types, extract them into a colocated .types.ts file.

src/components/user/
├── UserCard.tsx
├── UserList.tsx
└── user.types.ts       ← shared types for this module

This keeps related types close to the code that uses them without polluting a global file.

Pattern 3: A Shared types/ Directory for Cross-Cutting Definitions

When types are used across multiple unrelated modules, a central src/types/ directory makes sense. Use a barrel index.ts to keep imports clean and stable.

src/types/
├── index.ts            ← re-exports everything
├── api.types.ts
├── user.types.ts
└── product.types.ts
// src/types/index.ts
export type { ApiResponse, PaginatedResult } from "./api.types"
export type { User, UserRole } from "./user.types"

Consumers import from one path: import type { User } from "@/types". If you later reorganize the internals, the import paths don’t change.

Should You Use .ts or .d.ts for Type Definitions?

This is one of the most common points of confusion in TypeScript type organization.

Use .ts files for types you export and import explicitly. This is the right choice for almost everything in an application.

Use .d.ts files primarily for ambient declarations — situations where you need to tell TypeScript about something that exists at runtime but has no TypeScript source. Common examples:

  • env.d.ts — declaring import.meta.env variables for Vite or similar bundlers
  • global.d.ts — augmenting third-party module types or declaring global variables
// env.d.ts
/// <reference types="vite/client" />
interface ImportMetaEnv {
  readonly VITE_API_URL: string
}

Some modern projects configure compilerOptions.types explicitly to control which ambient types are loaded, while others rely on automatic @types discovery. If you’re using a bundler-based toolchain like Vite or Next.js, follow its recommended conventions for these files rather than creating freestanding global declarations.

Naming Conventions That Scale

Consistent naming reduces cognitive load across a team:

PatternExampleUse case
*.types.tsuser.types.tsModule-specific types
index.tstypes/index.tsBarrel re-export
env.d.tsenv.d.tsEnvironment variable declarations
global.d.tsglobal.d.tsThird-party type augmentation

Avoid IUser prefixes and UserType suffixes. Plain names like User and ApiResponse are cleaner and align with modern TypeScript conventions.

Organizing TypeScript Types in a Shared Package

If you’re building a library or working in a monorepo, expose types through package.json exports using the types condition rather than relying on older typesVersions patterns.

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    }
  }
}

Keep internal types private — don’t export them from the package entry point. Only the public API surface should be visible to consumers.

Conclusion

Start with types inline. Extract to a colocated file when a second file needs them. Promote to src/types/ when they’re truly cross-cutting. Use .d.ts files only for ambient declarations, not as a default home for all type definitions.

The goal of organizing TypeScript types isn’t a perfect folder structure — it’s making the next developer (or future you) able to find and reuse types without friction.

FAQs

No. A single file works early on but becomes hard to navigate as a project grows. Instead, keep types close to where they are used. Define them inline for single-file use, extract to a colocated .types.ts file when shared within a module, and only promote to a central types directory when they are needed across unrelated parts of the codebase.

Use .ts files for types you explicitly export and import in your application code. Use .d.ts files only for ambient declarations, such as describing environment variables or augmenting third-party module types. Placing regular application types in .d.ts files can cause confusion because those declarations are globally available without an explicit import.

A widely adopted convention is to use the pattern module-name.types.ts for module-specific types and an index.ts barrel file for re-exports in a shared types directory. Avoid Hungarian notation like IUser or redundant suffixes like UserType. Plain, descriptive names such as User and ApiResponse are preferred in modern TypeScript projects.

Use the types condition inside the exports field of your package.json to point to your compiled declaration file. This approach is more explicit and reliable than the older typesVersions pattern. Only export types that form part of your public API and keep internal types private to avoid leaking implementation details to consumers.

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Check our GitHub repo and join the thousands of developers in our community.

OpenReplay