Tutorial: your first boundary
August 1, 2026 · View on GitHub
This is a hands-on, ~10-minute walkthrough: create one encapsulation
boundary, trigger a real violation, and fix it three different ways. It
assumes no knowledge about ImportLint or JSDoc access tags beyond what's
explained inline — for the concepts behind each step, see
concepts.md.
Setup
You need Node.js (for npx) or a downloaded import-lint binary. Make an empty directory and step
into it:
mkdir import-lint-tutorial && cd import-lint-tutorial
Scaffold the config — the recommended package-by-default setup, built on the
*.package naming convention:
npx @import-lint/cli init
Wrote .importlintrc.jsonc
The generated file is fully commented — every option annotated in place. The two options that drive this walkthrough:
// Convention: any directory named "foo.package" is an encapsulation boundary.
// Everything inside it imports freely from everything else inside it; nothing
// outside can import an export unless it's tagged `@public`.
{
"rules": {
"package-access": {
// Every export is package-scoped by default (no JSDoc tag needed).
"defaultImportability": "package",
// A directory is a boundary because of its name, not its location.
"packageDirectory": ["**/*.package"]
}
}
}
Make a boundary
Create a src/auth.package/ directory — its name makes it a boundary under
the config above — with one function inside it, and one file outside that
tries to use it:
mkdir -p src/auth.package
src/auth.package/token.ts:
export function issueToken(userId: string): string {
return `token-for-${userId}`;
}
src/server.ts:
import { issueToken } from "./auth.package/token";
console.log(issueToken("alice"));
Your directory now looks like:
.
├── .importlintrc.jsonc
└── src
├── auth.package
│ └── token.ts
└── server.ts
Hit a real error
npx @import-lint/cli .
src/server.ts
1:10 error Cannot import a package-private export 'issueToken' package-access
✖ 1 problem (1 error, 0 warnings)
Exit code 1. issueToken has no JSDoc tag, so it defaults to
"package" importability (that's what defaultImportability set); token.ts
lives inside the auth.package boundary, and server.ts doesn't, which marks the
import as a violation. This is the thing ImportLint exists to catch: a
function that was only ever meant for auth.package's own internals, used
somewhere it shouldn't be.
There are three independent ways to fix this. Each one below assumes you're starting back from the violating state above.
Fix 1: tag the export @public
If issueToken really is meant to be used from anywhere, say so explicitly.
src/auth.package/token.ts:
/** @public */
export function issueToken(userId: string): string {
return `token-for-${userId}`;
}
npx @import-lint/cli .
No output, exit code 0 — pretty format prints nothing on a clean run.
Fix 2: re-export it through the boundary's index.ts
Revert the tag from Fix 1 first (delete the /** @public */ line) — the
violation is back:
src/server.ts
1:10 error Cannot import a package-private export 'issueToken' package-access
✖ 1 problem (1 error, 0 warnings)
Instead of tagging the function itself, add a bare re-export at the
boundary's own index.ts, and import through it instead of reaching
straight into token.ts.
src/auth.package/index.ts:
export { issueToken } from "./token";
src/server.ts, updated to import through the index:
import { issueToken } from "./auth.package";
console.log(issueToken("alice"));
npx @import-lint/cli .
No output, exit code 0. This works because of the index loophole
(concepts.md's Index loophole section): a
bare re-export in a boundary's index.ts promotes that export to the
boundary's parent package — src/, the same directory server.ts is in.
Unlike Fix 1, issueToken is not fully public — it is still inaccessible to anything two levels
away.
Fix 3: move the importer inside the boundary
Revert Fix 2 (delete src/auth.package/index.ts, and change server.ts's
import back to ./auth.package/token) — the violation returns. Instead of
changing what's exported, move the file that needs it into the boundary:
mv src/server.ts src/auth.package/server.ts
src/auth.package/server.ts, with the import path updated to match its new
location:
import { issueToken } from "./token";
console.log(issueToken("alice"));
npx @import-lint/cli .
No output, exit code 0. server.ts is now in the same package as
token.ts, so no tag or re-export is needed at all.
Conclusion
In this tutorial, you've created a boundary, hit a real violation, and seen three independent, valid ways to resolve it — which one is "correct" depends entirely on whether the export was meant to be public API, a curated re-export surface, or purely internal to code that should just live inside the boundary.
However, @public is rarely the right choice; it makes the export available everywhere, which is usually not what you want. The other two fixes are more common: either re-export it through the boundary's index.ts so that the boundary can control its public API, or move the importer into the boundary so it can use the export without exposing it to the outside world.
There is also an escape hatch for violations you can't fix yet: a
// import-lint-disable-next-line directive comment above the import
suppresses the diagnostic at that one site, like ESLint's
eslint-disable-next-line — see the root README's
Suppressing a violation with a comment.
Where next
concepts.md— the full mental model: package directories, both loopholes and their cascade behavior, re-export semantics, and what counts as external vs. internal.adoption.md— choosing a starting configuration (package-by-default, annotation-driven, monorepo) for a real project, and a phased rollout strategy for retrofitting an existing codebase.