Share Types Across a TypeScript Codebase

Learn how to organize and share TypeScript types across a large codebase using barrel files, path aliases, shared packages, and type-only imports.

7 min read

In a one-file script, types live next to the code that uses them. In a codebase with hundreds of files across multiple teams, that approach breaks down fast.

Two teams define the same User type slightly differently. A frontend imports a type that pulls in an entire backend module. Refactoring a shared interface means touching forty files across twelve directories.

Organizing TypeScript shared types well is about two things: where types live and how consumers import them. TypeScript gives you the tools to do both cleanly.

Where Types Should Live

Shared types belong in a dedicated location, not scattered through feature folders. The simplest structure uses a top-level directory with one file per domain concept:

texttext
src/
  types/
    user.ts          -- User, UserRole, UserPermissions
    order.ts         -- Order, OrderStatus, LineItem
    api.ts           -- ApiResponse<T>, PaginatedResult<T>
    index.ts         -- barrel file
  features/
    checkout/        -- imports from @/types
    dashboard/       -- imports from @/types

Each file in the types directory exports related type definitions. No file imports from features. The dependency direction is one-way: features depend on types, never the reverse.

In a monorepo, shared types should be their own package with a proper package.json:

texttext
packages/
  shared-types/
    src/
      user.ts
      index.ts
    package.json     -- "name": "@acme/shared-types"
    tsconfig.json
  web/               -- depends on @acme/shared-types
  api/               -- depends on @acme/shared-types

This forces an explicit version boundary. When you change a shared type, every consumer gets the change consistently and the compiler catches breakages.

Use Path Aliases for Clean Imports

Long relative imports are fragile and hard to read. Instead of importing from three directories up, use a path alias configured in tsconfig:

jsonjson
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@shared/types/*": ["./src/types/*"]
    }
  }
}

This path alias transforms a fragile relative import into a clean, stable reference that survives directory restructuring without any code changes:

typescripttypescript
import type { User } from "@shared/types/user";

The path alias has a second benefit: if you move the types directory later, you change one line in tsconfig instead of updating hundreds of import paths across the project.

Choose alias prefixes that signal intent. A prefix like @shared/types tells you the import is a shared type, while @shared/utils tells you it is a utility function.

Avoid generic prefixes that do not distinguish concerns.

Use type-only Imports

When a module exports both values and types, a normal import pulls in everything. If you only need the type, use the import type syntax, which is erased at compile time and produces zero JavaScript:

typescripttypescript
import type { User } from "@shared/types/user";

This documents that the importing module has no runtime dependency on the types package. The same pattern applies to re-exports from barrel files:

typescripttypescript
export type { User, UserRole } from "./user";
export { createUser } from "./user";

This separation makes the intent immediately visible. Type-only exports produce zero JavaScript and document that the importing module has no runtime dependency. Value exports signal that the consumer will execute code from the imported module.

Barrel Files: Use Them Carefully

A barrel file re-exports everything from a directory through a single index.ts entry point. Consumers import from one place instead of from individual files:

typescripttypescript
// src/types/index.ts
export type { User, UserRole } from "./user";
export type { Order, OrderStatus } from "./order";

Barrel files are convenient but come with two risks in large codebases.

First, circular imports. If order.ts imports from user and user.ts imports from order, the barrel file creates a cycle that may not surface until runtime.

Second, slow resolution. When TypeScript resolves an import from a barrel, it loads every file the barrel re-exports. If your barrel re-exports 40 files, every import pays the cost of resolving all 40.

The fix is to keep barrel files flat. Never create a barrel that re-exports from another barrel.

For large projects, import directly from the source file when you need only one type. This avoids the barrel entirely and resolves a single file.

Keep Domain Types Distinct from API and DB Types

A shared types package should contain domain types, not API shapes or database schemas. These three layers change for different reasons and at different speeds. A database column rename should not force every frontend to recompile.

Keep API shapes in a contracts package, database rows near the storage layer, and domain types in the shared package:

typescripttypescript
// shared-types: domain types
type User = { id: string; email: string };
 
// api-contracts: API shapes
type CreateUserBody = { email: string; password: string };
 
// db: storage shapes
type UserRow = { id: string; email: string; created_at: Date };

Map between them at the boundaries. A repository converts a database row to a domain type. A controller converts an API request body to the arguments a service expects.

Handle Breaking Changes to Shared Types

When you change a shared type, the compiler tells you which files break. That feedback loop is valuable, but it works against you if every small change triggers a hundred-file rebuild.

To minimize the blast radius, add fields as optional first with a deprecation comment, then make them required after consumers migrate. Export narrow, focused types per consumer instead of one giant type. In a monorepo, version the shared types package so consumers opt into breaking changes at their own pace.

For more on keeping boundaries clean, see set package boundaries in TypeScript.

Common Mistakes

Exporting types alongside database clients in the same file. If a file exports both a User type and a database query, every frontend import pulls in the database driver transitively. Separate types from implementations.

Deep barrel chains. When one index file re-exports from another index file which re-exports from a third, invisible coupling spreads and tree-shaking becomes impossible. Keep barrels one level deep.

Using export star in barrel files. This re-exports everything, including internal types you meant to keep private. Always use named exports so the public API is explicit.

Putting runtime code in a types file. A file that exports both types and const enums breaks under the isolatedModules compiler option. Keep pure type files with only type-level exports.

For more on organizing a project around clear module boundaries, see organize TypeScript project folders.

Rune AI

Rune AI

Key Insights

  • Put shared types in a dedicated directory or internal package, not alongside application code.
  • Use tsconfig path aliases for clean, stable imports that survive directory moves.
  • Prefer import type for all type-only imports: it documents intent and prevents runtime coupling.
  • Avoid deep barrel file chains: keep index.ts files flat and never re-export from sibling barrels.
  • In monorepos, publish shared types as a workspace package with its own tsconfig and version.
RunePowered by Rune AI

Frequently Asked Questions

Where should I put shared types in a TypeScript project?

Create a dedicated top-level directory or internal package. Use tsconfig path aliases so consumers import from a clean name instead of long relative paths. In monorepos, publish shared types as a workspace package with its own version.

Should I use barrel files for types?

Yes, but keep them flat. Barrel files make imports cleaner by re-exporting types from a single entry point. However, deep barrel chains cause circular dependencies and slow module resolution. Never re-export from sibling barrel files.

Conclusion

Shared types are the vocabulary of a large TypeScript codebase. When every team imports from a well-organized shared package using clean path aliases, nobody re-defines the same interface twice, and the compiler guarantees consistency across every consumer.