Originally posted at zohaib.me
"Good fences make good neighbors." — Robert Frost, Mending Wall
Working on large projects has its problems of scale. Certain languages provide solution to these problems and other rely on tools in ecosystem to solve them. This post is a solution to two such problem in TypeScript.
When you have 10s - 100s of developers working on a single TypeScript codebase, who are part of larger organization, contributing across various projects in the code, dependency creep becomes a problem as the boundaries of various projects and modules isn't clear. Even with code reviews it becomes harder to track dependencies and contracts of your module (distinguish between what is private and what is public).
We are going to solve two problems here
- Have explicit boundary of package i.e. what is importable from other packages and what is internal to package.
- Dependency creep i.e. explicitly call out dependencies of a package so its easier to manage them
In a language like C# you have DLLs and the modifier internal
which makes it clear what are the various projects you are using and what can be used from those projects without taking dependency on anything private to that. We can have a similar boundary in TypeScript using path mapping and a tool good-fences.
Let's look a the structure of an example project in which we create a full name using fullName
function and log it in console.
packages
|__ app
| |__ lib
| |__ index.ts
|__ hello
|__ lib
|__ index.ts
|__ fullName.ts
Here app
and hello
will act like packages in the project and app
will take a dependency on hello
to generate a full name.
import { fullName } from 'hello';
function app() {
console.log(fullName('Zohaib', 'Rauf'));
}
app();
hello
is not a NPM package but instead we use the TypeScript path mapping capability which allow us to refer to a certain directory/path using an alias.
The aliases can be turned into relative paths using Webpack.
Boundary of package
Now we have the package defined and use it. We expect that whoever will use the package will always refer to it using hello
so it refers to hello/lib/index.ts and never refer to any of internal files e.g. hello/lib/fullName.ts. We rely on everyone doing the right thing which in practice is hard when you have 10s - 100s developer working.
Nothing is stopping them using it in these ways hence taking a hard dependency on the location of an internal function
- hello/lib/fullName
- ../../hello/lib/fullName
Now lets use good-fences to solve this problem. We need to create a file fence.json
in a folder which we want to fencify.
{
"tags": ["hello"],
"exports": ["./lib/index"],
"imports": [],
"dependencies": []
}
Here tags
refer to the tag associated with this project so that other fence.json
can refer to this package. We explicitly call out what is exported from this package and what are its dependencies. Here imports
refers to other packages within the project (their fence.json tag) which it uses and dependencies
are the NPM packages this package uses.
We'll use gulp
to run good-fences before I compile so the build breaks at compile time
const goodFences = require('good-fences');
gulp.task('check-fences', function() {
return new Promise((resolve, reject) => {
const result = goodFences.run({rootDir: './'});
if (result.errors && result.errors.length > 0) {
const message = result.errors
.map(err => err.detailedMessage)
.join('\n');
reject(new Error(message));
} else {
resolve();
}
});
});
We use run()
method which goes over all the fence files and ensure the boundaries are respected.
Lets see what happens when we use import { fullName } from '../../hello/lib/fullName'
Dependency creep
We have figured out what gets exported which solves the problem of what is internal to package and what is external i.e. importable by others. Now lets look into how we can solve the dependency creep by having fences for imports.
{
"tags": ["app"],
"exports": ["lib/index.ts"],
"imports": [],
"dependencies": []
}
Here we explicitly call out that there is no dependency or import, in app package and lets see what good-fences throws. Recall we use hello
in the package.
This can be solved by adding "imports": ["hello"]
and now we have made an explicit decision to use that as dependency. During code review you can see the file changing and make sure this dependency is needed or not.
If I start using a NPM package, lets take example of using is-odd
(because I'm lazy to write one line function :P) then your fence file will look like below
{
"tags": ["app"],
"exports": ["lib/index.ts"],
"imports": ["hello"],
"dependencies": ["is-odd"]
}
Incremental adoption
You don't have to adopt good-fences across all your codebase to start seeing its advantages. You can start small and then add fences as you go along. You can add fence.json
on any new package you create and in this way you can start fixing things as you go along.
I would recommend going over the documentation of good-fences to learn more about it.
Top comments (0)