Setup Modern TypeScript Project
“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.🙏
- How to Create Your Own TypeScript Library in 2024: A Step-by-Step Guide
- Create a consumer for your library
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 | { |
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 | app |
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 | { |
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 | import { defineConfig } from "tsup"; |
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 | { |
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 | import eslint from "@eslint/js"; |
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 | const message: string = "Hello, welcome to this demo application!"; |
Then, you can build and run the project with the following command.
1 | npm run build |
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 | { |
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 | import { defineConfig } from "tsup"; |
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 | # under 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 | src |
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 | // lib/index.ts |
lib-b
As we already have a demo for project structure, we just write our stuff in index.ts
here.
1 | import { Phantom, ThePhantom } from "lib-a"; |
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 | import { Phantom, ThePhantom } from "lib-a"; |
What will it output? The same as the comment? Run npm run start
, and we get this.
1 | 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 | // ../lib-a/dist/index.mjs |
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 | import { defineConfig } from "tsup"; |
Now, rebuild lib-b
and app
, you will see the expected output.😆
1 | Phantom: Sing, my Angel of Music! |
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 | import { defineConfig } from "vitest/config"; |
Then, add a script to package.json
.
1 | "scripts": { |
Finally, we can write the test file, let’s call it test/index.test.ts
.
1 | import { expect, test } from "vitest"; |
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 | npm login |
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. ᓚᘏᗢ