Building TypeScript Libraries' A Guide to Explicit Exports, Tree Shaking, and Common Pitfalls
20 Feb 2025Building a TypeScript library that is both maintainable and optimised for modern bundlers requires careful consideration of exporting functions, types, and other constructs from your modules.
With several strategies available, from wildcard re-exports to namespaced exports, explicitly named re-exports have emerged as the clear winner. This post explores the alternatives with examples based on an order management system.
We'll discuss the benefits of exporting types, how to organise multiple entry routes (such as in /src/orders
and /src/users
), and review the necessary TypeScript configuration options (such as verbatimModuleSyntax) and package.json tweaks. Additionally, we'll explain how this approach helps prevent circular dependency challenges by enforcing a clear dependency graph.
Building a library
Imagine you are developing a library for an order management system that handles order lookups, cancellations, and creation. Your project is organised into distinct domains:
- Orders – containing all logic related to order lookups, cancellations, and creation
- Users – managing user profiles, authentication, and related queries
Your public API should enable consumers to import functions and types naturally. For instance, they should be able to write:
import { lookupOrder, cancelOrder } from './orders';
import type { Order } from './orders';
import { getUser, updateUser } from './users';
The challenge is organising your exports to achieve maximum tree-shaking efficiency, avoid naming collisions, and offer a seamless experience for value and type-only imports.
Alternative Exporting Approaches
1. Re-Exporting with export * from
A common method is to re-export everything from a module:
// src/orders/index.ts
export * from './lookup';
export * from './commands';
Pros:
- Concise and straightforward
- Automatically exposes all exports from the underlying modules
Cons:
- While modern bundlers generally handle tree shaking well, wildcard exports can occasionally lead to ambiguity in complex dependency graphs
- There is a higher risk of naming collisions if multiple modules export identically named identifiers
- Consumers may have less clarity about which symbols form part of the public API
2. Namespace Exports with export * as ns from
Another option is to group exports under a namespace:
// src/orders/index.ts
export * as lookup from './lookup';
export * as commands from './commands';
Consumers would then import and access functions as follows:
import { lookup } from './orders';
lookup.lookupOrder(...);
Pros:
- Minimises the risk of naming collisions by encapsulating exports within a namespace
- Clearly indicates the origin of each function
Cons:
- Introduces an extra level of nesting, which can be less ergonomic
- Consumers must always reference the namespace even if they only require one function
3. Explicit Named Re-Exports (Our Recommended Approach)
We recommend explicitly listing the exports in your domain's index file. For example, in the domain of the order:
// src/orders/index.ts
export { lookupOrder, cancelOrder } from './lookup';
export { createOrder } from './commands';
export type { Order, OrderStatus } from './types';
And in the users domain:
// src/users/index.ts
export { getUser, listUsers } from './queries';
export { updateUser } from './commands';
Pros:
- Tree Shaking & Bundle Optimisation: Explicit exports enable bundlers such as Webpack, Rollup, and esbuild to eliminate unused code accurately
- Collision Avoidance: Explicitly listing exports prevents accidental name collisions
- Consumer Experience: Consumers benefit from a clean and direct import syntax without unnecessary nesting
- API Clarity: The public API is clearly defined and straightforward to document
- Prevention of Circular Dependencies: By clearly specifying the exports for each module, you enforce a transparent dependency graph
- Future-Proofing: This approach works seamlessly with modern ECMAScript Modules and TypeScript's verbatimModuleSyntax option
Cons:
- Requires manual updating of index files when new functions or types are added
- Involves slightly more maintenance overhead compared to wildcard re-exports (though modern IDEs can often automate this process)
TypeScript Configuration & Package.json Adjustments
To fully leverage explicit named re-exports in the ESM era, update your tsconfig.json as follows:
{
"compilerOptions": {
"module": "esnext",
"target": "esnext",
"verbatimModuleSyntax": true,
// other options...
}
}
The verbatimModuleSyntax option ensures that TypeScript leaves your ES module import/export statements untouched, essential for effective tree shaking by modern bundlers.
In your package.json, expose your public API entry points:
{
"name": "my-library",
"version": "1.0.0",
"type": "module",
"exports": {
"./orders": "./dist/orders/index.js",
"./users": "./dist/users/index.js"
},
"scripts": {
"build": "tsc"
}
}
Multiple Entry Routes: A Concrete Example
Assuming your project is structured like this:
src/
orders/
lookup.ts // Contains lookupOrder, cancelOrder, etc.
commands.ts // Contains createOrder.
types.ts // Contains Order, OrderStatus, etc.
index.ts // Explicitly re-exports functions and types.
users/
queries.ts // Contains getUser, listUsers, etc.
commands.ts // Contains updateUser.
index.ts
Exporting Types: Why It Matters
Explicitly exporting types along with functions offers several advantages:
- Clarity for Consumers: Exported types allow users to write precise type annotations that match your public API
- Avoids Global Namespace Pollution: Consumers import only what they need, keeping the global namespace clean
- Enhanced Tree Shaking: Modern bundlers can drop unused type-only imports when using the import type syntax
- Consistency: This approach aligns with the broader industry move towards explicit, module-based API definitions
- Circular Dependency Prevention: By clearly defining which types are exported, you reduce the risk of circular dependencies
For example, rather than using a wildcard re-export for types:
// Avoid: Not ideal for public libraries
export * from './types';
It is preferable to use explicit type exports:
export type { Order, OrderStatus } from './types';
Conclusion
Explicitly named re-exports remain the most recommended approach for public libraries in 2025. They provide:
- Superior tree-shaking and bundle optimisation
- A straightforward, unambiguous public API that avoids naming collisions
- A smooth consumer experience with direct, idiomatic imports for both values and types
- Clear benefits in preventing circular dependency issues by enforcing a well-defined dependency structure
- Future-proof compatibility with modern TypeScript options such as verbatimModuleSyntax
Organising your exports explicitly in each domain's index file for a library with multiple domains, such as orders and users, ensures that your public API is robust, optimised, and easy to maintain as the ecosystem evolves.
Hopefully, this guide helps you build more maintainable, optimised, and future-proof libraries.