Logo
EN

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).

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.