Avoid Type Leaks in Large TypeScript Projects

Learn how to prevent TypeScript types from leaking across module boundaries, creating unwanted coupling between packages that should remain independent.

7 min read

A TypeScript type leak is when a type from one part of your codebase appears somewhere it should not. The code compiles fine. The tests pass.

But underneath, an invisible dependency has formed. Change the database schema and suddenly a React component fails to compile. That is a type leak.

TypeScript type leaks are the most common architectural decay in large projects. They happen quietly because TypeScript makes cross-module imports so easy.

One import statement and two packages that were supposed to be independent are now coupled forever.

How Type Leaks Happen

The most common type leak path is through shared types. A domain type is placed in a shared package, which is correct.

But the domain type references a database-specific type, which is imported into the shared package from the database layer. Now every consumer of the shared package transitively depends on the database.

Another common path is through barrel files. A barrel file re-exports everything from a directory, including internal types that were never meant to leave the package. A consumer imports one public type from the barrel and accidentally pulls in five internal types it should never see.

The third path is through return types. A service function returns a type that includes a database row shape because the implementation queries the database and passes the raw row through without mapping it to a domain type.

Detect Leaks by Following the Import Chain

The fastest way to find type leaks is to trace what a seemingly innocent import actually pulls in. Start from a consumer that should only depend on domain types and follow the chain of imports to see how far it reaches.

A frontend component that imports a User type from a shared package should not pull in Express types, database drivers, or Node.js APIs. If it does, a type leak exists somewhere in the chain. The component might only use the User type at compile time, but the import resolution forces TypeScript to load every transitive dependency of the file that exports User.

The fix is to split the User type into its own file that imports nothing except other pure types. The service functions that operate on User live in a separate file that can import whatever it needs. The frontend imports only the types file and never pulls in the runtime dependencies.

Fix Leaks with Layer-Specific Types

The most reliable fix for type leaks is to maintain separate types per layer and map between them at boundaries. A database row type, a domain type, and an API response type are three different things that should live in three different places.

Here is what the mapping looks like in practice. Each layer has its own shape, and a dedicated function converts between them:

typescripttypescript
// Database layer: what the DB returns
type UserRow = { user_id: string; email_address: string; password_hash: string; created_at: string };
 
// Domain layer: what business logic uses
type User = { id: string; email: string };
 
// API layer: what the client receives
type UserResponse = { id: string; email: string };
 
// Boundary mapper: DB to domain
function toDomain(row: UserRow): User {
  return { id: row.user_id, email: row.email_address };
}

The password hash stays in the database layer. The timestamp stays in the database layer unless the domain genuinely needs it.

The API response does not include either field. Each layer gets exactly the data it needs and no more.

This means a database migration that renames a column only affects the database layer and the mapper function. The domain logic, the services, and the frontend are all insulated because they never see the raw column names.

Control What Barrel Files Re-Export

A barrel file that uses export star leaks every type from every file in the directory. Replace it with explicit named exports so the public surface is deliberate and reviewed:

typescripttypescript
// Bad: leaks everything, including internal types
export * from "./user-repository";
export * from "./user-service";
 
// Good: deliberate public surface
export { createUser, getUser } from "./user-service";
export type { User, CreateUserInput } from "./user-types";

The internal types in user-repository never appear in the public API. Consumers cannot accidentally depend on them because they cannot import them. If a consumer truly needs a type from the repository layer, that is a signal that the architecture needs reconsidering, not that the export should be added.

For more on controlling public surfaces, see design public TypeScript APIs.

Use import type to Prevent Runtime Leaks

Even when compile-time coupling is intentional, runtime coupling should never be accidental. The import type syntax tells TypeScript that an import is type-only and should be erased during compilation. If the imported module exports only types, import type produces zero JavaScript.

This is especially important when a frontend imports from a shared package that also exports runtime code. Without import type, the bundler includes the runtime code in the frontend bundle even if only types are used. With import type, the bundler sees no runtime dependency and tree-shakes the import entirely:

typescripttypescript
// This pulls runtime code into the frontend bundle
import { User, formatUserName } from "@acme/shared";
 
// This only imports the type; no runtime code in the bundle
import type { User } from "@acme/shared";

The same principle applies to backend code. A service that only needs a type from a utility module should use import type to document that there is no runtime dependency, making it safe to refactor the utility without testing the service.

Common Mistakes

Re-exporting dependency types in public barrels. If your package wraps a third-party library, do not re-export the library's types in your public API. Your consumers should not need to know what library you use internally.

Putting database column types in domain type files. A domain User type with a created_at field that only exists because the database has it is a type leak waiting to spread. Keep database concerns in the database layer.

Importing server-only modules in shared code. A shared package that imports fs, path, or process.env works fine in Node.js but leaks into the frontend bundle. Shared code should be environment-agnostic or split into browser and server exports.

Assuming import type fixes all coupling. Import type prevents runtime leaks but does not prevent compile-time coupling. A React component that import types a database row shape is still coupled to the database schema conceptually. Map to domain types at the boundary.

For more on keeping codebases healthy over time, see maintain large TypeScript codebases.

Rune AI

Rune AI

Key Insights

  • A type leak is when a type from one layer appears in another layer where it does not belong.
  • Use import type to prevent runtime code from leaking through type-only imports.
  • Never re-export a type from a dependency in your public barrel file unless consumers genuinely need it.
  • Map between layer-specific types at boundaries: DB row to domain type to API response.
  • A frontend that imports a type and also pulls in a database driver transitively has a type leak.
RunePowered by Rune AI

Frequently Asked Questions

What is a type leak in TypeScript?

A type leak happens when a type from one module or package unintentionally appears in the public API or implementation of another, creating a hidden dependency. For example, a frontend component importing a type that transitively pulls in a database driver is a type leak.

Does import type prevent all type leaks?

It prevents runtime leaks by erasing the import at compile time. However, it does not prevent compile-time coupling. If your component imports a database type via import type, the component still depends on the database schema conceptually. The fix is to map database types to domain types at the boundary.

Conclusion

Type leaks are the silent killer of large TypeScript projects. They look harmless because the code compiles, but they create invisible coupling that makes every module harder to change independently. Control your exports, use import type, map between layers, and let the compiler tell you when a dependency has crept where it does not belong.