Fence your TypeScript, for saner project boundaries

Solve the problem of dependency creep and add boundaries to your TypeScript project to limit what is exported and imported from a package.
"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

  1. Have explicit boundary of package i.e. what is importable from other packages and what is internal to package.
  2. 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.

Lets 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();
/packages/app/lib/index.ts: Here app uses the function fullName from package hello

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.

{
    ...
    "baseUrl": "./packages",
    "paths": {
        "hello": [
            "hello/*",
            "hello/lib/*",
            "hello/lib/index.ts"
        ]
    },
    ...
}
tsconfig.json: Path mapping for hello

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": []
}
hello/fence.json

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(); 
        }
    }); 
});
gulpfile.js

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": []
}
app/fence.json

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"]
}
app/fence.json: Adding is-odd NPM package as a dependency

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.

Resources