A “bare bones” TypeScript Library Setup
Let’s start by creating new small library from nothing, so you can see how my “lots of value out of few tools” approach keeps things nice and simple.
Getting Started
Installing volta
First, make sure you have the latest version of volta
installed on your machine
If you’re using a POSIX-compliant operating system like macOS, linux or Windows with Windows Subsystem for Linux (WSL) run
sh
curl https://get.volta.sh | bash
If you want to install volta for native windows support (e.g. powershell or cmd.exe
), you can install the latest .msi
package from the latest release.
Follow the installation instructions you may see as part of the installation process, which may involve closing your terminal and opening it again to start a new session.
Installing node
and yarn
through volta
Have volta download versions of node and yarn
sh
volta install node@lts yarn@^3.0.0
You should see it download the appropriate versions of node
and yarn
.
sh
success: installed and set node@18.18.2 as defaultsuccess: installed and set yarn@3.6.4 as default
It’s not important what specific versions these are, as part of what volta does for us is ensure you obtain and use the right versions for each project.
Cloning the project repo
clone the workshop project
sh
git clone git@github.com:mike-north/typescript-coursescd typescript-coursesyarn
You may see volta obtain a new version of yarn
and node
(if necessary), and then it should install all of the relevant dependencies
The beginnings of the project
First, create a new directory in the packages/
folder and enter it
sh
cd packagesmkdir chat-stdlibcd chat-stdlib
Then, create a .gitignore
file
sh
npx gitignore node
and a package.json file
sh
yarn init --yesyarn config set nodeLinker node-modules
Add the following fields your packages/chat-stdlib/package.json
file
json
{"main": "dist/index.js","types": "dist/index.d.ts","scripts": {"build": "yarn tsc","dev": "yarn build --watch --preserveWatchOutput","lint": "yarn eslint src --ext js,ts","test": "yarn jest"},"license": "NOLICENSE"}
and make sure to save the file. This ensures that TS and non-TS consumers alike can use this library, and that we can run the following commands
sh
yarn build # build the projectyarn dev # build, and rebuild when source is changedyarn lint # run lintingyarn test # run tests
These commands won’t do anything yet, because each of them requires a tool we have yet to install
Pin the node and yarn versions to their current stable releases using volta
sh
volta pin node@lts yarn@^3
this will add node
and yarn
versions to your package.json
automatically.
diff
+ "volta": {+ "node": "18.18.2",+ "yarn": "3.6.4"+ }
Note that we’re using an LTS version of node
, which is what the Node.js project tells us to do
LTS release status is “long-term support”, which typically guarantees that critical bugs will be fixed for a total of 30 months. Production applications should only use Active LTS or Maintenance LTS releases.
Source: nodejs.dev/en/about/releases/
TypeScript Compiler
Install typescript as a devDependency
, which establishes two important things
- TypeScript is included at build time, and not packaged with the library as a runtime dependency
- Consumers of this library do not need to use the same version of TypeScript being used to build this library. They don’t necessarily need to use TypeScript at all.
sh
yarn add -D typescript@5.3.0-beta
Setting up your tsconfig
Create a default tsconfig.json
sh
yarn tsc --init
And add a compiler option to ensure that we target the ES2022 language level (allowing for features like async
and await
, as well as Ecma privave #fields
).
diff
"compilerOptions": {/* Language and Environment */+ "target": "ES2022",}
Next, let’s change some settings to customize the how the TypeScript compiler treats modules
- tell the TS compiler to create Node-friendly CommonJS modules
- require explicit specification of types that should be used in the
src/
folder, as opposed to allowing free reign to access anything that might be in thenode_modules
folder
diff
"compilerOptions": {/* Modules */+ "module": "commonjs",+ "rootDir": "src",+ "types": [],}
Next, let’s describe the output of the TS compiler, ensuring that everything ends up in the /dist
folder, declaration (.d.ts
) files are emitted as well, and any types marked with the JSDoc tag @internal
are omitted from publicly visible types
diff
"compilerOptions": {/* Emit */+ "declaration": true,+ "outDir": "dist",+ "stripInternal": true,},
Let’s make sure two potentially problematic features are disabled. We’ll talk later about why these are not great to leave enabled for a library.
diff
"compilerOptions": {/* Interop Constraints */+ "esModuleInterop": false,/* Completeness */+ "skipLibCheck": false}
Let’s make sure that we have an “extra strict” type-checking configuration, appropriate for a green field typescript library.
diff
"compilerOptions": {/*** "strict": true,* -------------------* - noImplicitAny* - strictNullChecks* - strictFunctionTypes* - strictBindCallApply* - strictPropertyInitialization* - noImplicitThis* - alwaysStrict*//* Type Checking */+ "strict": true,+ "useUnknownInCatchVariables": true,+ "noUnusedLocals": true,+ "noUnusedParameters": true,+ "exactOptionalPropertyTypes": true,+ "noImplicitReturns": true,+ "noUncheckedIndexedAccess": true,+ "noImplicitOverride": true,+ "noPropertyAccessFromIndexSignature": true,}
We’ll go in to more detail later about what some of these options mean, and why I suggest setting them this way.
Finally we need to define an area for our source code. Add one more line to your tsconfig.json
diff
{"compilerOptions": {...- }+ },+ "include": ["src", ".eslintrc.js"]}
create a folder for your source code, and make an empty index.ts
file within it
sh
mkdir srctouch src/index.ts
Open src/index.ts
and set its contents to the following
ts
/*** @packageDocumentation A small library for common chat app functions*//*** A class that represents a deferred operation.* @public*/export class Deferred<T> {// The promise object associated with the deferred operation.#_promise: Promise<T>/*** The function to call to resolve the deferred operation.*/#_resolve!: Parameters<ConstructorParameters<typeof Promise<T>>[0]>[0]/*** The function to call to reject the deferred operation.*/#_reject!: Parameters<ConstructorParameters<typeof Promise<T>>[0]>[1]/*** Creates a new instance of the Deferred class.*/constructor() {this.#_promise = new Promise<T>((resolve, reject) => {this.#_resolve = resolvethis.#_reject = reject})}/*** Gets the promise object associated with the deferred operation.*/get promise() {return this.#_promise}/*** Gets the function to call to resolve the deferred operation.*/get resolve() {return this.#_resolve}/*** Gets the function to call to reject the deferred operation.*/get reject() {return this.#_reject}}/*** Stringify an Error instance* @param err - The error to stringify* @internal*/export function stringifyErrorValue(err: Error): string {return `${err.name.toUpperCase()}: ${err.message}${err.stack || '(no stack trace information)'}`}/*** Stringify a thrown value** @param errorDescription - A contextual description of the error* @param err - The thrown value* @beta*/export function stringifyError(err: unknown, errorDescription?: string) {return `${errorDescription ?? "( no error description )"}\n${err instanceof Error? stringifyErrorValue(err): err? '' + err: '(missing error information)'}`}
This is obviously convoluted, but it’ll serve our purposes for looking at some interesting behavior later.
Let’s make sure that things are working so far by trying to build this project.
sh
rm -rf dist # clear away any old compiled outputyarn build # build the projectls dist # list the contents of the dist/ folder
You should see something like
sh
index.d.ts index.js
Make a commit! We have working build script.
sh
git add -A ../..git commit -m "Build is working"
Linting
Install eslint as a development dependency
sh
yarn add -D eslint
and go through the process of creating a starting point ESLint config file
sh
yarn eslint --init
When asked, please answer as follows for the choices presented to you:
- How would you like to use ESLint?
- To check syntax and find problems
- What type of modules does your project use
- JavaScript modules (import/export)
- Which framework does your project use?
- None of these
- Does your project use TypeScript?
- Yes
- Where does your code run?
-
Both (check both options)
- What format do you want your config file to be in?
- JavaScript
- Would you like to install them now?
- Yes
- Which package manager are you using?
- yarn
Let’s also enable a set of rules that take advantage of type-checking information
diff
--- a/packages/chat-stdlib/.eslintrc.js+++ b/packages/chat-stdlib/.eslintrc.js@@ -5,7 +5,8 @@},"extends": ["eslint:recommended",- "plugin:@typescript-eslint/recommended"+ "plugin:@typescript-eslint/recommended",+ "plugin:@typescript-eslint/recommended-requiring-type-checking"],"parser": "@typescript-eslint/parser",
There’s one rule we want to enable, and that’s a preference for const
over let
. While we’re here,
we can disable ESLint’s rules for unused local variables and params, because the TS
compiler is responsible for telling us about those
diff
--- a/packages/chat-stdlib/.eslintrc.js+++ b/packages/chat-stdlib/.eslintrc.js@@ -14,5 +14,6 @@},"plugins": ["@typescript-eslint"],"rules": {+ "prefer-const": "error",+ "@typescript-eslint/no-unused-vars": "off",+ "@typescript-eslint/no-unused-params": "off"}}
Going back to our /.eslintrc.js
, we need to tell ESLint about this new TS config — rules that require type-checking need to know about where it is
diff
--- a/packages/chat-stdlib/.eslintrc.js+++ b/packages/chat-stdlib/.eslintrc.js@@ -4,14 +4,17 @@"parserOptions": {- "ecmaVersion": "latest"+ "ecmaVersion": "latest",+ "project": true,+ "tsconfigRootDir": __dirname},}
While we’re in here, let’s set up some different rules for our test files compared to our source files, by adding a new object to the overrides
array
json
{"files": "tests/**/*.ts","env": { "node": true, "jest": true }}
And one more modification to the override for the .eslintrc.js
file itself
json
extends: ["plugin:@typescript-eslint/disable-type-checked"],rules: {"@typescript-eslint/no-unsafe-assignment": "off",}
Let’s make sure this works by running
sh
yarn lint
You should see a linting error
.../typescript-courses/packages/chat-stdlib/src/index.ts 75:24 error Invalid operand for a '+' operation. Operands must each be a number or string, allowing a string + any of: `any`, `boolean`, `null`, `RegExp`, `undefined`. Got `{}` @typescript-eslint/restrict-plus-operands 75:24 error 'err' will evaluate to '[object Object]' when stringified
The problem occurs here
tsTry
/*** Stringify a thrown value** @param errorDescription - A contextual description of the error* @param err - The thrown value* @beta*/export functionstringifyError (err : unknown,errorDescription ?: string) {return `${errorDescription ?? "( no error description )"}\n${err instanceofError ?stringifyErrorValue (err ):err ? '' +err : '(missing error information)'}`}
ESLint is warning us about a {} -> string
coercion using the +
operator. We can either change this to use the String
constructor
diff
- ? '' + err+ ? String(err)
Running lint
again should indicate that ESLint no longer objects.
sh
yarn lint
Make a commit! We have working lint command.
sh
git add -A .git commit -m "Linting with ESLint is working"
Testing
Next, let’s install our test runner, and associated type information, along with some required babel plugins
sh
yarn add -D jest @types/jest @babel/core @babel/preset-env @babel/preset-typescript
and make a folder for our tests, and create a file to contain the tests for our src/index.ts
module
sh
mkdir teststouch tests/index.test.ts
tests/index.test.ts
tsTry
// @filename: tests/index.test.tsimport {Deferred ,stringifyError } from 'chat-stdlib'describe ('Utils - Deferred', () => {letdeferred :Deferred <string>beforeEach (() => {deferred = newDeferred ()})it ('should create a new instance with a promise', () => {expect (deferred .promise ).toBeInstanceOf (Promise )})it ('should resolve the promise when calling resolve', async () => {consttestValue = 'Resolved Value'deferred .resolve (testValue )awaitexpect (deferred .promise ).resolves .toBe (testValue )})it ('should reject the promise when calling reject', async () => {consttestError = newError ('Rejected Error')deferred .reject (testError )awaitexpect (deferred .promise ).rejects .toThrow (testError )})it ('should have resolve and reject methods', () => {expect (typeofdeferred .resolve ).toBe ('function')expect (typeofdeferred .reject ).toBe ('function')})})describe ('Utils - stringifyError', () => {it ('should stringify an Error instance correctly', () => {consterrorDescription = 'Test Error'consttestError = newError ('This is a test error')constexpectedString = `${errorDescription }\n${testError .name .toUpperCase ()}: ${testError .message }\n${testError .stack }`constresult =stringifyError (testError ,errorDescription )expect (result ).toBe (expectedString )})it ('should stringify a non-Error value correctly', () => {consterrorDescription = 'Test Error'consttestValue = 'This is a test value'constexpectedString = `${errorDescription }\n${testValue }`constresult =stringifyError (testValue ,errorDescription )expect (result ).toBe (expectedString )})it ('should handle missing error information', () => {consterrorDescription = 'Test Error'constexpectedString = `${errorDescription }\n(missing error information)`constresult =stringifyError (null,errorDescription )expect (result ).toBe (expectedString )})it ('should handle Error instance without a stack trace', () => {consterrorDescription = 'Test Error'consttestError = newError ('This is a test error without stack')deletetestError .stack constexpectedString = `${errorDescription }\n${testError .name .toUpperCase ()}: ${testError .message }\n(no stack trace information)`constresult =stringifyError (testError ,errorDescription )expect (result ).toBe (expectedString )})})
We’ll need to make a one-line change in our existing /tsconfig.json
file
diff
--- a/tsconfig.json+++ b/tsconfig.json@@ -1,4 +1,5 @@{"compilerOptions": {+ "composite": true,
and to create a small tests/tsconfig.json
just for our tests
tests/tsconfig.json
json
{"extends": "../tsconfig.json","references": [{ "name": "chat-stdlib", "path": ".." }],"compilerOptions": {"types": ["jest"],"rootDir": ".."},"include": ["."]}
and a small little babel config at the root of our project, so that Jest can understand TypeScript
.babelrc
json
{"presets": [["@babel/preset-env", { "targets": { "node": "18" } }],"@babel/preset-typescript"]}
Take it for a spin
At this point, we should make sure that everything works as intended before proceeding further.
Run
sh
yarn test
to run the tests with jest. You should see some output like
sh
PASS tests/index.test.tsUtils - Deferred✓ should create a new instance with a promise (2 ms)✓ should resolve the promise when calling resolve✓ should reject the promise when calling reject (3 ms)✓ should have resolve and reject methodsUtils - stringifyError✓ should stringify an Error instance correctly✓ should stringify a non-Error value correctly✓ should handle missing error information✓ should handle Error instance without a stack trace (1 ms)Test Suites: 1 passed, 1 totalTests: 8 passed, 8 totalSnapshots: 0 totalTime: 0.357 s, estimated 1 sRan all test suites.
Make a commit! We have the beginnings of a test suite in place.
sh
git add -A .git commit -m "Testing with Jest is working"
API Surface Report & Docs
We’re going to use Microsoft’s api-extractor as our documentation tool — but it’s really much more than that as we’ll see later
First, let’s install it
sh
yarn add -D @microsoft/api-extractor @microsoft/api-documenter
and let’s ask api-extractor
to create a default config for us
sh
yarn api-extractor init
This should result in a new file /api-extractor.json
being created. Open it
up and make the following changes
diff
diff --git a/packages/chat-stdlib/api-extractor.json b/packages/chat-stdlib/api-extractor.jsonindex c5b47c8..51da632 100644--- a/packages/chat-stdlib/api-extractor.json+++ b/packages/chat-stdlib/api-extractor.json@@ -43,11 +43,11 @@* The path is resolved relative to the folder of the config file that contains the setting; to change this,* prepend a folder token such as "<projectFolder>".** SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>*/- "mainEntryPointFilePath": "<projectFolder>/lib/index.d.ts",+ "mainEntryPointFilePath": "<projectFolder>/dist/index.d.ts",/*** A list of NPM package names whose exports should be treated as part of this package.** For example, suppose that Webpack is used to generate a distributed bundle for the project "library1",@@ -239,11 +239,11 @@*/"dtsRollup": {/*** (REQUIRED) Whether to generate the .d.ts rollup file.*/- "enabled": true+ "enabled": true,/*** Specifies the output path for a .d.ts rollup file to be generated without any trimming.* This file will include all declarations that are exported by the main entry point.*@@ -253,11 +253,11 @@* prepend a folder token such as "<projectFolder>".** SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>* DEFAULT VALUE: "<projectFolder>/dist/<unscopedPackageName>.d.ts"*/- // "untrimmedFilePath": "<projectFolder>/dist/<unscopedPackageName>.d.ts",+ "untrimmedFilePath": "<projectFolder>/dist/<unscopedPackageName>-private.d.ts",/*** Specifies the output path for a .d.ts rollup file to be generated with trimming for an "alpha" release.* This file will include only declarations that are marked as "@public", "@beta", or "@alpha".*@@ -265,11 +265,11 @@* prepend a folder token such as "<projectFolder>".** SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>* DEFAULT VALUE: ""*/- // "alphaTrimmedFilePath": "<projectFolder>/dist/<unscopedPackageName>-alpha.d.ts",+ "alphaTrimmedFilePath": "<projectFolder>/dist/<unscopedPackageName>-alpha.d.ts",/*** Specifies the output path for a .d.ts rollup file to be generated with trimming for a "beta" release.* This file will include only declarations that are marked as "@public" or "@beta".*@@ -277,11 +277,11 @@* prepend a folder token such as "<projectFolder>".** SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>* DEFAULT VALUE: ""*/- // "betaTrimmedFilePath": "<projectFolder>/dist/<unscopedPackageName>-beta.d.ts",+ "betaTrimmedFilePath": "<projectFolder>/dist/<unscopedPackageName>-beta.d.ts",/*** Specifies the output path for a .d.ts rollup file to be generated with trimming for a "public" release.* This file will include only declarations that are marked as "@public".*
Make an empty /etc
folder
sh
mkdir etc
and then run api-extractor for the first time
sh
yarn api-extractor run --local
This should result in a new file being created: /etc/chat-stdlib.api.md
. This is
your api-report. There’s also a /temp
folder that will have been created. You
should add this to your .gitignore
.
diff
--- a/.gitignore+++ b/.gitignore@@ -114,3 +114,5 @@ dist.yarn/build-state.yml.yarn/install-state.gz.pnp.*++# API Extractor working folder+temp
you may also notice that some new .d.ts
files are in your /dist
folder.
Take a look at the contents. Do you see anything interesting?
The last step we need to handle here is making dist/chat-stdlib.d.ts
the types that should be used by consumers of our module. Make this change to packages/chat-stdlib/package.json
json
- "types": "dist/index.d.ts"+ "types": "dist/chat-stdlib.d.ts"
API Docs
We can use api-documenter
to create markdown API docs by running
sh
yarn api-documenter markdown -i temp -o docs
This should result in the creation of a /docs
folder containing the markdown pages. Take a moment to look at these!
Finally, we should make a couple of new npm scripts to help us easily
generate new docs by running api-extractor
and api-documenter
sequentially
diff
--- a/package.json+++ b/package.json@@ -7,7 +7,10 @@"build": "tsc","watch": "yarn build --watch --preserveWatchOutput","lint": "eslint src tests --ext ts,js",- "test": "jest"+ "test": "jest",+ "api-report": "api-extractor run",+ "api-docs": "api-documenter markdown -i temp -o docs",+ "build-with-docs": "yarn build && yarn api-report && yarn api-docs"},"license": "MIT","volta": {
Make a commit! We have API extraction and a documentation generator in place.
sh
git add -A .git commit -m "API Extractor and API Documenter are working"
Making a change that affects our API
Let’s “enhance” our library by requiring that our stringifyError
function always be passed a errorDescription
of type string
. Just remove the optional ?
aspect of the errorDescription
’s type annotation.
diff
--- a/packages/chat-stdlib/src/index.ts+++ b/packages/chat-stdlib/src/index.ts@@ -68,7 +68,7 @@ ${err.stack || '(no stack trace information)'}`* @param err - The thrown value* @beta*/-export function stringifyError(err: unknown, errorDescription?: string) {+export function stringifyError(err: unknown, errorDescription: string) {return `${errorDescription ?? "( no error description )"}\n${err instanceof Error? stringifyErrorValue(err): errurn sum2(a, b) + sum2(c, d);}
Now run
sh
yarn build-with-docs
You should see something like
Warning: You have changed the public API signature for this project. Please copy the file "temp/chat-stdlib.api.md" to "etc/chat-stdlib.api.md", or perform a local build (which does this automatically). See the Git repo documentation for more info.
This is api-extractor
telling you that something that users can observe
through the public API surface of this library has changed. We can follow its instructions
to indicate that this was an intentional change (and probably a minor release instead of a patch)
sh
cp temp/chat-stdlib.api.md etc
and build the docs again
sh
yarn build-with-docs
You should now see an updated api-report. It’s now very easy to see the ramifications of changes to our API surface on a per-code-change basis! Imagine how much easier this makes discussions about public API changes in pull requests!
diff
--- a/packages/chat-stdlib/etc/chat-stdlib.api.md+++ b/packages/chat-stdlib/etc/chat-stdlib.api.md@@ -13,6 +13,6 @@ export class Deferred<T> {}// @beta-export function stringifyError(err: unknown, errorDescription?: string): string;+export function stringifyError(err: unknown, errorDescription: string): string;
Our documentation has also been updated automatically
diff
index 4d4dda0..fa4d35d 100644--- a/packages/chat-stdlib/docs/chat-stdlib.stringifyerror.md+++ b/packages/chat-stdlib/docs/chat-stdlib.stringifyerror.md@@ -12,7 +12,7 @@ Stringify a thrown value**Signature:**` ``typescript-export declare function stringifyError(err: unknown, errorDescription?: string): string;+export declare function stringifyError(err: unknown, errorDescription: string): string;` ``## Parameters@@ -20,7 +20,7 @@ export declare function stringifyError(err: unknown, errorDescription?: string):| Parameter | Type | Description || --- | --- | --- || err | unknown | The thrown value |-| errorDescription | string | _(Optional)_ A contextual description of the error |+| errorDescription | string | A contextual description of the error |**Returns:**
Make a commit! We’ve introduced the first change to our library’s public API
sh
git add -A .git commit -m "BREAKING: stringifyError - errorDescription is now required"
Congrats! we now have
- Compiling to JS
- Linting
- Tests
- Docs
- API surface change detection
without having to reach for more complicated tools like webpack!