TL;DR:
TypeScript lacks built-in support for treating directories as encapsulated modules, leading to broken encapsulation and unintended exports during refactoring or testing. To fix this, we propose the 'index.ts directory guard' pattern, where the index.ts at the root of a directory acts as the sole entry point, controlling access and enforcing a clean public API. We complement this pattern with a custom ESLint rule to make it enforceable and consistent.
Problem Statement by Example
Imagine a module with one public function and several helper functions and a file that uses it.
// myModule.ts
export function publicFunction() {
helperFunction1();
helperFunction2();
}
function helperFunction1() {
/* ... */
}
function helperFunction2() {
/* ... */
}
// iAmUsingMyModule.ts
import { publicFunction } from './myModule';
publicFunction();
example/
├── myModule.ts
└── iAmUsingMyModule.ts
Obviously, helperFunction1
and helperFunction2
are well protected, the myModule
module only exposes publicFunction
.
There is no way to access the helper functions outside of the module. Trying to import them would cause a compilation error.
// iAmUsingMyModule.ts with error
import { helperFunction1 } from './myModule'; // Error - Module '"./myModule"' declares 'helperFunction1' locally, but it is not exported. ts(2459)
Now, lets assume that in order to improve readability and modularity, the myModule.ts
is split and the helpers are extracted into their own file. Just for that sake of modularity and readability.
After trivial refactoring
example/
├── myModule.ts
├── myModuleHelpers.ts
└── iAmUsingMyModule.ts
// myModuleHelpers.ts
export function helperFunction1() {
/* ... */
}
export function helperFunction2() {
/* ... */
}
// myModule.ts
import { helperFunction1, helperFunction2 } from './myModuleHelpers';
export function publicFunction() {
helperFunction1();
helperFunction2();
}
Note that the helpers had to be exported to be used from myModule
, even though they are intended to remain private to it. This unintended exposure increases the risk of misuse by external modules.
The iAmUsingMyModule.ts
and any other file, would be able to access them as well:
// iAmUsingMyModule.ts
import { helperFunction1 } from './myModuleHelpers';
helperFunction1();
What just happened?
We lost the encapsulation of myModule functionality. The internal use helper functions are no longer private we and have no way of protecting them against outside access.
Among other things, we lost the bullet proof ability to change them and we lost the minimal interface exposed by the myModule. We now have several files and several exported functions instead of a clear single function.
None of this is intentional. We just wanted the module to be more readable and more maintainable and achieved the opposite!
Enter the index.ts
"Directory Guard" pattern
When a directory contains an index.ts
file, it serves as the sole entry point for importing any functionality from that directory. The directory’s internal files and subdirectories are considered private implementation details, inaccessible to external modules. Only items explicitly exported from index.ts
can be imported elsewhere.
"Directory Guard" file structure and content
example/
├── myModule/
│ ├── myModule.ts
│ ├── myModuleHelpers.ts
│ └── index.ts
└── iAmUsingMyModule.ts
With index.ts
reading:
// index.ts
export { publicFunction } from './myModule';
or even
// index.ts
export * from './myModule';
Note: Our recommendation is to keep the
index.ts
clean and minimalistic, using it solely for exports and avoiding any additional logic or functionality. Both of the above achieve this.
Immediate Result
This alone already gives us the benefit of visual and physical encapsulation of related code under the same roof, and the explicitness of importing from the directory, just like we did from the file:
example/
├── myModule/
└── iAmUsingMyModule.ts
// iAmUsingMyModule.ts
import { publicFunction } from './myModule';
publicFunction();
Benefits of the Pattern
Encapsulation and Information Hiding
- Internal files and helpers remain private, reducing the surface area of the module.
- Changes to internal implementation do not affect external consumers as long as the public API remains unchanged.
Cleaner, Minimal APIs
- External modules only interact with the public API defined in
index.ts
, leading to a well-defined and minimal interface.
Improved Maintainability
- The clear separation of public and private code simplifies refactoring and enhances code readability.
Aligned with Common Practices and Easy to Implement
- Many languages and ecosystems have similar mechanisms, such as:
- TypeScript’s
export
keyword for module-level visibility. - Public/private modifiers for classes in TypeScript and other languages like Java and C#.
- Package-level visibility controls in publishable packages like NPM.
- TypeScript’s
Enforcing the Pattern with ESLint
Although more readable and more expressive, the restructure is not enforceable yet. Unfortunately due to the shortcomings of typescript the internal functionality can still be accessed easily by importing directly from ./myModule/myModuleHelpers
.
// iAmUsingMyModule.ts
import { publicFunction } from './myModule';
import { helperFunction1 } from './myModule/myModuleHelpers'; // We want to disallow this!
publicFunction();
helperFunction1();
Of course, one can settle for relying on the convention, documentation and the good will of developers.
We wanted more!
Enter the index.ts
"Directory Guard" eslint rule
In order to enforce this pattern we created a supporting eslint rule:
- Prohibits direct imports from internal files within a directory.
- Requires all imports to go through the
index.ts
file at the root of the directory.
Example ESLint Rule
{
"rules": {
"@codygo/directory-guard": "error"
}
}
With this rule, the following import is disallowed:
// iAmUsingMyModule.ts
import { helperFunction1 } from './myModule/myModuleHelpers'; // Error: Internal file import
Instead, only this is permitted:
import { publicFunction } from './myModule';
Common Scenarios for unintentional exporting
The myModule
refactoring is not the only common scenario.
React Component Hierarchy
When used on a nested component hierarchy, it makes everything readable and prevents access to internal components that might be changed easily.
MainPage/
├── MainPage.tsx
├── index.ts
├── Layout/
│ ├── Layout.tsx
│ ├── index.ts
│ ├── Header/
│ │ ├── Header.tsx
│ │ ├── index.ts
│ │ ├── Navbar.tsx
│ │ └── SearchBar.tsx
│ ├── Sidebar/
│ │ ├── Sidebar.tsx
│ │ ├── index.ts
│ │ ├── Menu.tsx
│ │ └── SubMenu.tsx
│ └── Footer/
│ ├── Footer.tsx
│ ├── index.ts
│ ├── Links.tsx
│ └── SocialMedia.tsx
└── Content/
├── Content.tsx
├── index.ts
├── Dashboard.tsx
└── Reports/
├── Reports.tsx
├── index.ts
├── ReportTable.tsx
└── ReportChart.tsx
No one can nor should be able to access MainPage/Layout/Header/Navbar
.
Only the Header.ts
is supposed to access it using import { Navbar } from ./Navbar;
.
Anyone outside the MainPage
can only have access to what is exported by the MainPage/index.ts
which ideally is just export { MainPage } from '/MainPage
.
On top of the encapsulation and safety, you get a clear and concise structure as well which hides the gory details at any level.
MainPage/
├── MainPage.tsx
├── index.ts
├── Layout/
└── Content/
MainPage/
├── MainPage.tsx
├── index.ts
├── Layout/
│ ├── Layout.tsx
│ ├── index.ts
│ ├── Header/
│ ├── Sidebar/
│ └── Footer/
└── Content/
├── Content.tsx
├── index.ts
├── Dashboard.tsx
└── Reports/
Exporting internal function for tests
Another, shorter example is the habit of exporting functions (or other constructs such as variables or classes) for the sake of testing.
Building on our original example, lets consider the following file:
example/
├── myModule.ts
├── myModule.test.ts
// myModule.ts
export function publicFunction() {
helperFunction1();
helperFunction2();
}
function helperFunction1() {
/* ... */
}
function helperFunction2() {
/* ... */
}
// myModule.test.ts
import test from 'node:test';
import assert from 'node:assert';
import { publicFunction } from './myModule';
test('publicFunction should execute without errors', () => {
assert.doesNotThrow(() => {
publicFunction();
}, 'publicFunction threw an error when it should not have');
});
In order to test helperFunction1
we must export it:
// myModule.ts
export function publicFunction() {
helperFunction1();
helperFunction2();
}
export /* exported to allow tests only */ function helperFunction1() {
/* ... */
}
export /* exported to allow tests only */ function helperFunction2() {
/* ... */
}
Merely changing the structure to ours and adding an index.ts
exporting only the public function fixes this:
// index.ts
export { publicFunction } from './myModule';
example/
├── myModule/
│ ├── myModule.ts
│ ├── myModule.test.ts
│ └── index.ts
Conclusion
The index.ts
directory guard pattern aligns directory structures with established encapsulation principles, fostering cleaner APIs and maintainable codebases. By enforcing this with an ESLint rule, teams can ensure consistency and avoid pitfalls such as unintended exports or broken encapsulation during refactoring. As TypeScript evolves, we hope for native support for directory-level visibility controls, but until then, this pattern serves as a robust alternative.