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.
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:
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 @/typesEach 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:
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-typesThis 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:
{
"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:
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:
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:
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:
// 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:
// 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
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.
Frequently Asked Questions
Where should I put shared types in a TypeScript project?
Should I use barrel files for types?
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.
More in this topic
TypeScript Compiler Explained
The TypeScript compiler (tsc) turns .ts files into .js files while checking types. Learn what it does, how to use it, and how it fits your workflow.
Set Up ESLint for TypeScript
Install and configure ESLint with typescript-eslint to catch bugs and enforce consistent code style in your TypeScript project.
Pick Utility Type in TypeScript
Pick creates a new type by selecting only the properties you name from an existing type. Use it to build focused types for specific views, API responses, or function parameters.