“Failure means you've now learned another valuable lesson that pushes you one step closer to success.”

— Steve Harvey

Prologue

In the last post (Understanding TypeScript Philosophy), I talked about some thoughts on TypeScript. Then, I think there should better be another post about how to actually setup a good TypeScript project. Well, in this post, I’m going to show you the way.😉

Before I start, I’d like to express my gratitude for the following articles, which inspired me a lot.🙏

As we know, “talk is cheap, show me the code”. In this post, I’ll show you with a demo project consisting of one application (app) and two libraries (lib-a and lib-b). All code of this post is available at TypeScriptDemo. Since it involves multiple projects, and it is inconvenient for you to jump back and forth between repositories, I use a monorepo to place them together.

Well, to start with TypeScript development, you must have Node.js environment ready. You should know it, though.


Basic Setup

Initializing workspace

Let’s take app for example. Create a directory called app/ as project root directory, and execute the following command in it. If you just want a quick initialization, add -y option.

1
npm init

After that, you will get a package.json under app/. Don’t worry about the content, as we will modify it later.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"name": "app",
"version": "1.0.0",
"description": "Demo application",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"demo"
],
"author": "Tony S.",
"license": "MIT"
}

Installing necessary packages

First, install TypeScript support and a build tool tsup.

1
npm install --save-dev typescript tsup

For better code style, you can install ESLint.

1
npm install --save-dev eslint @eslint/js typescript typescript-eslint

About npm install

You may have confusion about this --save-dev (a.k.a. -D) option. It is a flag indicating that the installed packages are only needed during development. That is to say, they only boost your productivity and are not required by the application itself. For example, typescript is only needed to compile your TypeScript code into JavaScript, then the only thing you need at production is the JavaScript.

Check out more information with npm help install.


Create Your Application

Initializing project structure

Before we write any code, let’s create some files. It is a classic TypeScript project structure, you put all your source files under src/, with some configuration files.

1
2
3
4
5
6
7
app
|-- src
| `-- index.ts
|-- eslint.config.mjs
|-- package.json
|-- tsconfig.json
`-- tsup.config.ts

Filling configurations

package.json

The most important changes here are main and scripts. You can fill other fields as you wish, and you can also add repository stuff here.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{
"name": "app",
"version": "1.0.0",
"description": "Demo application",
"main": "dist/index.js",
"scripts": {
"build": "tsup",
"start": "node dist/index.js",
"lint": "eslint src --fix"
},
"keywords": [
"demo"
],
"author": "Tony S.",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/Lord-Turmoil/TypeScriptDemo"
},
"bugs": {
"url": "https://github.com/Lord-Turmoil/TypeScriptDemo/issues"
},
"homepage": "https://github.com/Lord-Turmoil/TypeScriptDemo",
"devDependencies": {
// ...
}
}

For more information about package.json, see npm Docs - package.json.

tsup.config.ts

This file is how you want your project to be built. For our simple project, the following configuration should be sufficient.

1
2
3
4
5
6
7
import { defineConfig } from "tsup";

export default defineConfig({
entry: ["src/index.ts"],
format: ["cjs", "esm"],
clean: true
});

Here, each file in entry will be compiled and bundled into one standalone JavaScript file, so we can run them with node. We set clean to true to clean the output directory (by default is dist/) before each build to delete out old files.

I know I missed format, for which you can refer to Ship ESM & CJS in one Package and What the heck are CJS, AMD, UMD, and ESM in Javascript?. For CJS, tsup will output index.js, and for ESM, tsup will output index.mjs. Since we simply run .js, ESM option is not necessary here.

tsconfig.json

This file configures some common TypeScript properties., you can just copy it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
{
"include": [
"src"
],
"exclude": [
"**/*.test.ts"
],
"compilerOptions": {
"module": "esnext",
"target": "esnext",
"lib": [
"esnext",
"dom"
],
"declaration": true,
"strict": true,
"moduleResolution": "node",
"skipLibCheck": true,
"esModuleInterop": true,
"outDir": "dist",
"rootDir": "src",
"baseUrl": ".",
"paths": {
"~/*": [
"src/*"
]
}
},
}

One thing I’d like to say is lib under compilerOptions. If you want to use console.log, you should have dom included.

Also, you may pay attention to baseUrl and paths under compilerOptions, they configures the path alias so you can use absolute path in your project.

Sounds familiar? Reminds you of Configure Path Aliases in React/Vue With Vite?😁

For more information about tsconfig.json, check out What is a tsconfig.json.

eslint.config.mjs

This file defines ESLint rules, and is quite subjective. Here is my preference, force semi-colon and double quotes.

1
2
3
4
5
6
7
8
9
10
11
12
13
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";

export default tseslint.config(
eslint.configs.recommended,
tseslint.configs.recommended,
{
rules: {
semi: "error",
quotes: ["error", "double", { "avoidEscape": true }],
}
}
);

Check out ESLint - Configuration Files for more information.

Write the code

Since our topic today is project setup, the code we write doesn’t matter. We can put it simple here, and add our library later.

1
2
const message: string = "Hello, welcome to this demo application!";
console.log(message);

Then, you can build and run the project with the following command.

1
2
npm run build
npm run start

Great, you now have a good-looking TypeScript application!🎊


Create Your Library

A library is pretty much the same as application, only some slight differences in configuration.

Configurations

For a TypeScript library, you need to specify some extra entries in package.json, which is module and types.

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"name": "lib-a",
"version": "1.0.0",
"description": "Demo library A",
"main": "dist/index.js",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsup",
"lint": "eslint src --fix"
},
// ...
}

Then, for tsup.config.ts, we need to add an extra option to generate that d.ts. Also, as we now need .mjs, we need both CJS and ESM here.

1
2
3
4
5
6
7
8
import { defineConfig } from "tsup";

export default defineConfig({
entry: ["src/index.ts"],
format: ["cjs", "esm"],
clean: true,
dts: true,
});

Other configuration files are the same, and you can initialize two libraries with the same method.

In fact, as will introduced later, lib-b will depends on lib-a, which will introduce an unwanted behavior later using the tsup.config.ts given here. I will explain it later when we actually see it.

Local dependency

The reason we have two libraries here is to demonstrate a more complex scenario. In this case, lib-a is the core library, and lib-b is a application library on lib-a, then our application will refer to both of these libraries.

Since the packages are still under development, we cannot use npm install to install them. For now, we have to link them locally. This requires the package be added to global package first. To do this, run npm link under the library project. In our demo, you need to run npm link under both lib-a and lib-b.

I’m not sure about this, but somehow adding global package may ask for administrative permission. So you can run it with sudo.

Did you know that? The latest Windows 11 has sudo command, too!🙌

Then, to “install” local package, run npm link <package> under the project that reference it.

1
2
3
4
# under lib-b/
npm link lib-a
# under app/
npm link lib-a lib-b

Local packages will not be added to package.json, as they are not accessible for npm install. And because of this, they will be removed after you run npm install or npm link <some other package>. Therefore, you need to specify all local packages in one npm link ... command, and rerun it after you install other packages.

To update a local package, just re-build it. If you want to unlink local packages, just run npm install again. And if you want to delete a local package, run npm unlink where you ran npm link.

Write the code

lib-a

lib-a is our core library, with low-level definitions. Although it is simple, I used a slight verbose structure to demonstrate module management.

1
2
3
4
5
src
|-- lib
| |-- index.ts
| `-- Phantom.ts
`-- index.ts

In order to keep the top-level index.ts clean and ordered, you can create “barrel files” under each folder. This way, you won’t have a huge and messy index.ts.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// lib/index.ts
export * from "./Phantom";

// lib/Phantom.ts
export interface Phantom {
line: string; // a line in the musical
}

// expected to be a singleton
export const ThePhantom: Phantom = {
line: "Sing, my Angel of Music!"
};

// index.ts
export * from "./lib";

lib-b

As we already have a demo for project structure, we just write our stuff in index.ts here.

1
2
3
4
5
6
7
8
9
import { Phantom, ThePhantom } from "lib-a";

export function sing(phantom: Phantom) {
console.log(`Phantom: ${phantom.line}`);
}

export function defaultSing() {
sing(ThePhantom);
}

Note the use of global singleton ThePhantom here. Is it really a singleton?🤔

Use the library

Now, let’s add our libraries to the application. Run npm link lib-a lib-b under app/. Then, we can update our src/index.ts.

1
2
3
4
5
6
7
8
9
10
11
12
import { Phantom, ThePhantom } from "lib-a";
import { defaultSing, sing } from "lib-b";

const phantom: Phantom = {
line: "Sing once again with me"
};

defaultSing(); // Sing, my Angel of Music!

sing(phantom); // Sing once again with me
ThePhantom.line = "Our strange duet";
defaultSing(); // Our strange duet ???

What will it output? The same as the comment? Run npm run start, and we get this.

1
2
3
Phantom: Sing, my Angel of Music!
Phantom: Sing once again with me
Phantom: Sing, my Angel of Music!

How is this possible?😱We did modified the singleton, why does defaultSing() still output the same line?

Module Duplication

It seems to be supernatural. Well, the code tells no lie, so let’s check the final output JavaScript file at app/dist/index.js. Aha, there are two phantoms!😯Now you probably know why.

1
2
3
4
5
6
7
8
9
// ../lib-a/dist/index.mjs
var ThePhantom = {
line: "Sing, my Angel of Music!"
};

// ../lib-b/dist/index.mjs
var ThePhantom2 = {
line: "Sing, my Angel of Music!"
};

Since lib-b depends on lib-a, tsup will bundle lib-a into lib-b. Therefore when app references both lib-a and lib-b, app and lib-b will use their own copy of lib-a, causing the duplication.

Luckily, there is solution for this, which is quite straightforward. Just mark lib-a external in lib-b so that they won’t be bundled together. The modified tsup.config.ts is as follows, with lib-a in external field.

1
2
3
4
5
6
7
8
9
import { defineConfig } from "tsup";

export default defineConfig({
entry: ["src/index.ts"],
format: ["cjs", "esm"],
external: ["lib-a"],
clean: true,
dts: true,
});

Now, rebuild lib-b and app, you will see the expected output.😆

1
2
3
Phantom: Sing, my Angel of Music!
Phantom: Sing once again with me
Phantom: Our strange duet

Congratulations! Now you are able to write impressive TypeScript projects!🎉


Trivia

Unit testing

Still, to make this article complete, I’d like to talk about unit testing. Here, we use Vitest. Since testing is usually for library, we just add it to lib-b.

1
npm install --save-dev vitest

Don’t forget to re-link lib-a after installing vitest.😉

Then, create vitest.config.ts under lib-b/.

1
2
3
4
5
6
7
8
import { defineConfig } from "vitest/config";

export default defineConfig({
test: {
globals: true,
environment: "node",
},
});

Then, add a script to package.json.

1
2
3
4
"scripts": {
// ...
"test": "vitest run"
}

Finally, we can write the test file, let’s call it test/index.test.ts.

1
2
3
4
5
6
7
8
9
10
import { expect, test } from "vitest";
import { defaultSing, sing } from "../src/index";

test("sing", () => {
expect(sing({ line: "Sing" })).toBe("Sing");
});

test("default sing", () => {
expect(defaultSing()).toBe("Sing, my Angel of Music!");
});

Now, run npm run test, you can see two passed tests.

Publishing your package

Eventually, if you have some good packages for others to use, you can publish them to npm registry.

First, register an account at npmjs.com. Then, just run the following commands.

1
2
npm login
npm publish

There you go. Now your package can be accessed by developers all over the world!🌏


Epilogue

Perhaps a forced habit, I just want to do things right, and always pursue the best practice. Is it good? You tell me. ᓚᘏᗢ