Building a Reusable TypeScript Library with Jest: A Comprehensive Boilerplate Guide
Overview
In this article, we will set up a complete boilerplate from scratch for creating a typed and unit-tested library. We will use Vite, TypeScript, Jest, Prettier, and ESLint in the process.
As a disclaimer, it is worth mentioning that this article is not focused on creating a server-side application, but rather a library that can be used in any JavaScript project.
Also I use pnpm
as package manager, but you can use npm
or yarn
if you want.
Tools
TypeScript, helps us to write type-safe code in JavaScript and catch errors at compile time rather than runtime, leading to more robust and maintainable code.
Jest is a JavaScript testing framework maintained by Facebook, it allows us to write tests for our code and run them in a browser or Node.js environment.
Prettier is a code formatter that helps us to format our code consistently and avoid formatting issues.
ESLint is a linter that helps us to catch errors in our code and enforce best practices.
Setup
TypeScript
tsc is the TypeScript compiler, it is used to compile TypeScript code into JavaScript code, install it globally using npm:
npm install -g typescript && tsc --version
Initialize
Let's create a new directory and initialize a new TypeScript project:
mkdir ts-jest-boilerplate && cd ts-jest-boilerplate && pnpm init && mkdir src && touch src/index.ts
At this point, you should have a package.json
file in your directory. But typescript is not installed yet.
├── package.json
├── pnpm-lock.yaml
└── src
└── index.ts
How TypeScript works
TypeScript code is written in .ts
files, which are then compiled into standard JavaScript using the TypeScript compiler (tsc
). For example, the following TypeScript code:
// src/index.ts
function add(a: number, b: number): number {
return a + b;
}
// dist/index.js
function add(a, b) {
return a + b;
}
This process transforms TypeScript's type annotations and other features into plain JavaScript that can run in any environment where JavaScript is supported, such as web browsers and Node.js.
TypeScript Setup
We must install the typescript
package in our project. As you can remember, we install the same package globally, which will be great so that you can execute this Script file without having to configure the project, but this one that is installed directly in the project will be the one used to generate the compilation.
pnpm add -D typescript ts-node @types/node
ts-node
is designed to run TypeScript code directly in a Node.js environment (this project) without the need for you to manually compile the TypeScript files using the TypeScript compiler (tsc).
On the other hand we have the @types/node
package, which will allow us to identify typing errors when writing code because they are the typescript definitions for the functions, parameters, classes, arguments and much more that already exist within node .
Make sure to add the start
script in you ts-jest-boilerplate/package.json
file:
{
"name": "ts-jest-boilerplate",
"version": "1.0.0",
"scripts": {
"start": "ts-node src/index.ts"
},
"license": "ISC",
"devDependencies": {
"typescript": "^5.5.4"
}
}
If you add the the following code into the src/index.ts
file and run pnpm run start
:
const greet = (name: string) => {
return `Hello, ${name}`;
};
console.log(greet("World"));
You will get in the console:
Hello, World
This is not completed so far, let's move to the next step.
TypeScript Configuration
At the root of the project, run the command:
npx tsc --init
In the root of the project will be created a tsconfig.json
like the following:
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}
The tsconfig.json
file is a configuration file used by the TypeScript compiler (tsc) to specify the compiler options and settings for a TypeScript project.
You can read more about the tsconfig.json
file here or here
I'll details some of the most important you need to know, when you start a new project.
compilerOptions.target
The target option defines the version of ECMAScript that your TypeScript code will be compiled down to. This means that any modern JavaScript features used in your TypeScript code will be transformed into equivalent code that is compatible with the specified ECMAScript version.
Taking into account that ECMAScript are framework versions within javascript that allow the use of a friendly syntax across its different versions, with this parameter you can specify to the compiler which of that syntax it can use according to a specific version of ECMAScript.
Keep in mind that the version you use may at some point have some incompatibility, if it is imported into a project that uses a fairly old version or if it will run in an environment such as a browser quite well.
The truth is that this type of problem is rare because most of the browsers used support the latest versions, so you can choose the one that best suits your needs.
compilerOptions.module
You can say that setting compilerOptions.module will tell the compiler how it should generate imports and exports of javascript code.
Imagine that you have the following import form in your typescript code.
// typescript
import { myFunction } from './myModule';
export { myFunction };
Compiled output with "module": "commonjs":
// javascript commonjs
var myModule = require('./myModule');
exports.myFunction = myModule.myFunction;
Compiled output with "module": es6
or "module": es2015
:
import { myFunction } from './myModule';
export { myFunction };
Compiled output with "module": esnext
to use the latest supported ECMAScript module syntax when generating the output JavaScript code.
The choice will depend on the place where you want to use this library. In my case I plan to use it in a react frontend and also in a backend, which will support ECMAScript imports and exports.
compilerOptions.strict
The strict option is designed to enforce stricter type-checking rules in TypeScript. By enabling this option, you can catch more errors at compile time, leading to safer and more maintainable code.
If the strict mode is enabled, the following errors will be caught because the arguments are not typed:
function add(x, y) {
return x + y;
}
const result = add(5, 10);
console.log(result); // Outputs: 15
compilerOptions.esModuleInterop
When esModuleInterop is enabled, TypeScript allows you to import CommonJS modules using the ES Module syntax. This means you can use the import statement to import modules that were originally designed for CommonJS.
Additionally, it modifies the way TypeScript handles default exports from CommonJS modules, allowing you to use the import x from 'module' syntax even when the module does not have a default export.
import express from 'express'; // This will cause an error without esModuleInterop
const app = express();
You would receive an error indicating that the default import is not allowed because the module is being imported as a CommonJS module.
compilerOptions.forceConsistentCasingInFileNames
In simple words it can be said that this setting verifies that the name of the file being imported is case sensitive.
compilerOptions.skipLibCheck
Consider a scenario where you have a declaration file with an error:
// custom.d.ts
declare function greetSomeone(name: string): string;
declare function greetSomeone(name: NonExistentType): string; // Error: NonExistentType does not exist
If skipLibCheck
is enabled, TypeScript will not report the error in custom.d.ts, but it will still check your usage:
// app.ts
greetSomeone('Jack'); // This call is checked against the declared function
If skipLibCheck
is disabled, TypeScript would report an error for the non-existent type in the declaration file.
If you are using libraries that may have type definitions with errors, enabling skipLibCheck allows you to bypass those errors and continue development without being blocked by issues in external libraries.
By skipping type checks on declaration files, you risk allowing type errors from third-party libraries to go unnoticed. This could lead to runtime errors if the types are incorrect or incompatible with your code.
Here’s a comprehensive list of configurations that can be passed in the compilerOptions
object within a TypeScript tsconfig.json
file, along with a brief description of each option:
List of compilerOptions
target
: Specifies the ECMAScript target version for the output JavaScript (e.g., es5
, es6
, es2017
, esnext
).
module
: Defines the module code generation method (e.g., commonjs
, amd
, es6
, umd
, system
).
lib
: Specifies a list of library files to be included in the compilation, allowing you to use specific features from different ECMAScript versions or DOM APIs.
allowJs
: Allows JavaScript files to be compiled alongside TypeScript files.
checkJs
: Enables error reporting in JavaScript files, allowing TypeScript to check for type errors in .js
files.
jsx
: Specifies how JSX code should be emitted (e.g., preserve
, react
, react-native
).
declaration
: Generates corresponding .d.ts
files for TypeScript and JavaScript files in your project.
declarationMap
: Creates sourcemaps for the generated declaration files, aiding in debugging.
sourceMap
: Generates corresponding .map
files for the emitted JavaScript files, allowing for better debugging.
outFile
: Concatenates and emits output to a single file, useful for libraries.
outDir
: Redirects the output structure to the specified directory.
rootDir
: Specifies the root directory of input files, controlling the output directory structure.
strict
: Enables all strict type-checking options, promoting better type safety.
noImplicitAny
: Raises an error on expressions and declarations with an implied any
type.
strictNullChecks
: Makes null
and undefined
distinct types, enforcing stricter checks.
strictFunctionTypes
: Ensures function types are checked contravariantly.
strictBindCallApply
: Ensures that the built-in methods call
, bind
, and apply
are invoked with the correct argument types.
strictPropertyInitialization
: Checks that class properties are initialized in the constructor before they are accessed.
alwaysStrict
: Ensures that all code is parsed in strict mode, emitting "use strict";
directives.
skipLibCheck
: Skips type checking of declaration files (.d.ts
), improving compilation speed.
forceConsistentCasingInFileNames
: Enforces consistent casing in file names, preventing issues across different operating systems.
esModuleInterop
: Enables interoperability between CommonJS and ES Modules, allowing default imports from modules without default exports.
allowSyntheticDefaultImports
: Allows default imports from modules with no default export, affecting type checking but not emitted code.
moduleResolution
: Determines how modules are resolved (e.g., node
, classic
).
baseUrl
: Specifies the base directory to resolve non-relative module names.
paths
: Maps module names to locations relative to the baseUrl, allowing for easier imports.
incremental
: Enables incremental compilation, which improves build times by only recompiling changed files.
composite
: Enables project references, allowing TypeScript projects to depend on other projects.
noEmit
: Prevents the compiler from emitting output files, useful for type-checking without generating JavaScript.
noErrorTruncation
: Disables truncation of error messages, providing full error details.
diagnostics
: Outputs compiler performance information after building.
emitDecoratorMetadata
: Enables emitting design-type metadata for decorators, useful for frameworks that use decorators.
experimentalDecorators
: Enables support for experimental decorators, allowing the use of decorator syntax.
resolveJsonModule
: Allows importing JSON files as modules.
suppressImplicitAnyIndexErrors
: Suppresses errors for implicit any
types in indexed access.
useUnknownInCatchVariables
: Changes the type of variables in catch
clauses from any
to unknown
, enforcing type checks.
noImplicitThis
: Raises an error when this
is implicitly of type any
.
allowUmdGlobalAccess
: Allows accessing UMD globals from modules.
allowUnreachableCode
: Disables error reporting for unreachable code.
allowUnusedLabels
: Disables error reporting for unused labels.
emitBOM
: Emits a Byte Order Mark (BOM) at the start of output files.
charset
: Specifies the character set of the input files (deprecated).
experimentalAsyncFunctions
: Enables support for asynchronous functions.
noFallthroughCasesInSwitch
: Reports errors for fallthrough cases in switch statements.
noImplicitOverride
: Ensures that overrides of base class methods are marked with the override
modifier.
noUncheckedIndexedAccess
: Makes indexing types more strict, ensuring that accessing an index checks for existence.
useDefineForClassFields
: Uses the define
semantics for class fields, aligning with the class fields proposal in JavaScript.
skipDefaultLibCheck
: Skips type checking of default library declaration files.
Typescript declarations
When developing a library in TypeScript, generating declaration files allows other to use your library in their TypeScript projects with proper type checking and autocompletion support.
The declaration option compilerOptions.declaration
, when set to true, instructs the TypeScript compiler to generate corresponding .d.ts
files for each TypeScript file in the project.
These declaration files contain type information about the exported entities (functions, classes, interfaces, etc.) in the TypeScript files but do not include the actual implementation code.
For example, if you have a file named myModule.ts with the following content:
export function greet(name: string): string {
return `Hello, ${name}!`;
}
The compiler will generate a corresponding myModule.d.ts
file with the following content:
export declare function greet(name: string): string;
compilerOptions.declartionMap
The compilerOptions.declarationMap
option in TypeScript is a configuration setting that, when enabled, generates source maps for the declaration files (.d.ts
) produced by the TypeScript compiler.
The declarationMap option allows the TypeScript compiler to create a .d.ts.map file for each declaration file generated. This map file contains information that links the types declared in the .d.ts file back to the original TypeScript source code from which they were derived.
If you are developing a TypeScript library that will be consumed by other developers, enabling declarationMap allows users to easily navigate from type definitions in the .d.ts files back to the original TypeScript source code.
compilerOptions.outFile
The outFile option allows you to concatenate all global (non-module) code or all module code into a single JavaScript output file.
compilerOptions.outDir
The outDir
option allows you to redirect the output structure of the compiled JavaScript files to a specific directory.
When outDir is specified, the compiled .js (as well as .d.ts, .js.map, etc.) files will be emitted into the specified directory, outDir
is commonly used to separate the source code from the compiled output, keeping the project structure organized.
compilerOptions.sourceMap
Source maps allow you to debug your TypeScript code directly in the browser or IDE, as they provide a mapping between the generated JavaScript and the original TypeScript source.
End TypeScript Configuration
Based on the objective of this exercise, which is a library, I have completed all the necessary configurations.
{
"exclude": [
"node_modules",
"dist",
"public",
"**/*.spec.ts",
"**/*.spec.tsx",
"**/*.test.ts"
],
"include": ["src/**/*.ts", "src/**/*.tsx"],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"target": "es2016",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": false,
"declaration": true,
"declarationMap": true,
"removeComments": true,
"sourceMap": true
}
}
Build process
The build process refers to the series of steps involved in transforming TypeScript source code into a format that can be consumed by users, typically JavaScript files along with type definition files (.d.ts
).
- Convert TypeScript code into JavaScript
- Generate
.d.ts
files that provide type information to users of the library. - Combine multiple JavaScript files into a single file (or a few files) to reduce the number of HTTP requests and improve load times.
- Reduce the size of the output files by removing whitespace, comments, and renaming variables, which helps in optimizing the library for production use.
- Create source maps that map the compiled JavaScript back to the original TypeScript source.
It can be said that this process is carried out once you want to make a Release of the library project, among other things that are being developed.
For this I will add the package.json
script to effectively generate the build process.
{
"name": "ts-jest-boilerplate",
"version": "1.0.0",
"scripts": {
"start": "ts-node src/index.ts",
"build": "tsc"
},
"license": "ISC",
"devDependencies": {
"@types/node": "^22.5.4",
"ts-node": "^10.9.2",
"typescript": "^5.5.4"
}
}
And I will also add other necessary configurations to the tsconfig.json
for the compiler to carry out the build process in the most appropriate way for the library.
{
"exclude": [
"node_modules",
".dist",
"public",
"**/*.spec.ts",
"**/*.spec.tsx",
"**/*.test.ts"
],
"include": ["src/**/*.ts", "src/**/*.tsx"],
"compilerOptions": {
"outDir": ".dist",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"target": "es2016",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": false,
"declaration": true,
"declarationMap": true,
"removeComments": true,
"sourceMap": true
}
}
We can run the pnpm run build
command and a
TypeScript Node
Additionally, within the configurations we can include the "ts-node" parameter where we can establish all the necessary configurations to effectively run the local project while it is in development.
// ...
"ts-node": {
"transpileOnly": true,
"files": true
}
// ...
For now I will not add anything related to this, the ones that come by default are enough.
Typescript Setup Result
My lib structure result like the following:
├── .dit
├── node_modules
│ ├── ts-node -> .pnpm/ts-node@10.9.2_@types+node@22.5.4_typescript@5.5.4/node_modules/ts-node
│ ├── @types
│ │ └── node -> ../.pnpm/@types+node@22.5.4/node_modules/@types/node
│ └── typescript -> .pnpm/typescript@5.5.4/node_modules/typescript
├── src
│ └── index.ts
├── package.json
├── pnpm-lock.yaml
└── tsconfig.json
Jest Configuration
pnpm add -D jest ts-jest @types/jest
jest test library for javascript.
ts-jest Allows to write tests in TypeScript while leveraging Jest's features. ts-jest
compiles TypeScript files into JavaScript on the fly, enabling seamless integration of TypeScript with Jest.
@types/jest TypeScript type definitions for Jest.
Jest Configuration
npm init jest@latest
or
pnpm create jest@latest
The following questions will help Jest to create a suitable configuration for your project
✔ Would you like to use Jest when running "test" script in "package.json"? … yes
✔ Would you like to use Typescript for the configuration file? … yes
✔ Choose the test environment that will be used for testing › node
✔ Do you want Jest to add coverage reports? … no
✔ Which provider should be used to instrument code for coverage? › v8
✔ Automatically clear mock calls, instances, contexts and results before every test? … no
✏️ Modified ts-jest-boilerplate/package.json
And the result of my jest.config.ts
is the following:
/**
* For a detailed explanation regarding each configuration property, visit:
* https://jestjs.io/docs/configuration
*/
import type { Config } from "jest";
const config: Config = {
coverageProvider: "v8",
preset: "ts-jest",
testEnvironment: "node",
testMatch: ["**tests/**/*.test.ts", "**tests/**/*.spec.ts"],
};
export default config;
I added a test case and then run pnpm run test
, make sure you has the script configured.
// test/index.test.ts
import { greet } from "../src/index";
describe("greet function", () => {
it("should return a greeting message", () => {
expect(greet("World")).toBe("Hello, World");
});
});
That's all
Well, this is all the configuration for Jest, to be honest, it is not that complex, this would be enough to run it from the project, create the library and do many other things.
If you want to go a little deeper about Jest configurations and different types of test cases, check out the following article where I mention all this in detail, using this same project configuration.