Turn JavaScript Into TypeScript Compatible Packages | by Jason Sturges | Feb, 2022

Generate TypeScript compatible NPM packages from pure JavaScript projects

Jason Sturges

Perhaps as a JavaScript developer, you’ve been apprehensive to convert your NPM packages to TypeScript. Or, maybe you have legacy codebases not worth investing time to port over.

However, you’d like your JavaScript packages inclusive to the TypeScript community, able to leverage your NPM package in their projects.

In my JavaScript projects, I’ve always leveraged the power of JSDoc annotations for types in my projects, which my IDE understands.

In the JavaScript example above, JSDoc defines the parameter and return types:

/**
* Return the average of an array of numbers.
*
* @param
{number[]} arr Array of numbers to be averaged
* @returns
{number} Average of numbers
*/
const average = (arr) => arr.reduce((a, b) => a + b) / arr.length;

This empowers my IDE to provide code completion / IntelliSense that indicates the signature (arr: number[]) and return value (number) types.

If I enter invalid values, it highlights errors. For example, if I enter string values ​​instead of number in my average() function:

And, I can view a full compilation of errors from my Problems window.

Even for more complex typedefs and enums, JSDoc provides a lot of capabilities. For example, I have an enumeration of Lunar Phases which are string values:

/**
* Enumeration of lunar phases
*
*
@typedef {string} LunarPhase
*
@enum {LunarPhase}
*/
export const LunarPhase = {
NEW: "New",
WAXING_CRESCENT: "Waxing Crescent",
FIRST_QUARTER: "First Quarter",
WAXING_GIBBOUS: "Waxing Gibbous",
FULL: "Full",
WANING_GIBBOUS: "Waning Gibbous",
LAST_QUARTER: "Last Quarter",
WANING_CRESCENT: "Waning Crescent",
};

Say I have a function that returns the date of the next phase:

/**
*
@param {LunarPhase} phase
*
@returns {Date}
*/
const next = (phase) => {
// ...
return new Date();
};

My IDE is able to parse these types:

And again, warn of errors:

Of course, JSDoc doesn’t provide all the capabilities of TypeScript such as interfaces or generic template programming. However, it does satisfy types.

So now that you’ve written your awesome JavaScript module and published it to NPM, you’d like it to be leveraged within TypeScript projects.

After adding the module to the package.json, you get error:

TS7016: Could not find a declaration file for module ‘‘.

Since your project isn’t TypeScript, it didn’t built a d.ts declaration file, so all of your types are implicitly any.

There are different ways of handling this, from publishing @types Packages to modifying TypeScript strictness, but if you’ve leveraged JSDoc as I’ve described above, there’s a way to generate what you need.

To build the declarations file, we’ll need to add some packages to our development dependencies, configurations, and some scripts.

Dependencies

In your package.jsonadd the following dev dependencies:

  • typescript
  • @microsoft/api-extractor

If you’re building from Rollup, your dev dependencies might look something like:

"devDependencies": {
"@microsoft/api-extractor": "^7.19.4",
"@rollup/plugin-commonjs": "^21.0.1",
"@rollup/plugin-node-resolve": "^13.1.3",
"eslint": "^8.9.0",
"jsdoc": "^3.6.10",
"prettier": "^2.5.1",
"rimraf": "^3.0.2",
"rollup": "^2.67.2",
"typedoc": "^0.22.11",
"typescript": "^4.5.5"
}

TypeScript Configuration

For TypeScript support, we’ll need to add a tsconfig.json file. This will configure TypeScript to scan our JavaScript source to build declarations:

Add tsconfig.json to the root of the project:

{
"include": ["./src/**/*"],
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"outDir": "dist",
"strict": true,
"target": "es6"
}
}

Microsoft API Extractor Configuration

To compile the declaration file, Microsoft API Extractor needs to be configured to merge all d.ts files into a single index.d.ts for our package distribution.

Add api-extractor.json to the root of the project:

{
"projectFolder": ".",
"mainEntryPointFilePath": "<projectFolder>/build/index.d.ts",
"bundledPackages": [],
"compiler": {
"tsconfigFilePath": "<projectFolder>/tsconfig.json",
"overrideTsconfig": {
"compilerOptions": {
"outDir": "build"
}
}
},
"dtsRollup": {
"enabled": true,
"untrimmedFilePath": "<projectFolder>/dist/index.d.ts"
},
"apiReport": {
"enabled": false
},
"docModel": {
"enabled": false
},
"tsdocMetadata": {
"enabled": false
},
"messages": {
"compilerMessageReporting": {
"default": {
"logLevel": "none"
}
},
"extractorMessageReporting": {
"default": {
"logLevel": "none"
}
},
"tsdocMessageReporting": {
"default": {
"logLevel": "none"
}
}
}
}

Package Scripts

With dependencies and configurations out of the way, we just need to add a script to the package.json to run the TypeScript compiler and build the declaration file using Microsoft API Extractor.

Add the following scripts:

"scripts": {
"prebuild:types": "rimraf ./build",
"build:types": "tsc -p ./tsconfig.json --outDir build && api-extractor run",
},

Before building, it will clean the build/ folder. This is optional, but a good idea. You’ll need rimraf or equivalent package for deletion.

Then, the build:types script will execute TypeScript and build declarations via Microsoft API Extractor.

If you receive an error during this process, it may be that Microsoft API Extractor requires at least one TypeScript file.

You might see:

api-extractor 7.19.4 — https://api-extractor.com/

Using configuration from ./api-extractor.json

ERROR: Error parsing tsconfig.json content: No inputs were found in config file ‘tsconfig.json’. Specified ‘include’ paths were ‘[“**/*”]’ and ‘exclude’ paths were ‘[“build”]’.

Really obnoxious… still working to identify a better solution here, but basically we just need one TypeScript file in the root of the solution.

I create a build-declaractions.ts file with the following contents:

/**
* This file is required for Microsoft API Extractor
* to build declaration files.
*
* It is intentionally left blank.
*/

In addition to building the package, we can now build TypeScript declarations by executing your build script and build:types scripts:

Your JavaScript build distributable will be the same as before:

But now with a d.ts declarations file:

Now, your awesome NPM package can be loaded into TypeScript projects, as seen below in this React TypeScript App.tsx:

Another side effect of this is that we can leverage TypeDoc for documentation.

If you add the typedoc npm module to your dev dependencies, you can run either of the following scripts:

"scripts": {
"predoc:jsdoc": "rimraf ./docs",
"docs:jsdoc": "jsdoc -r src/*s -R README.md -d ./docs",

"predoc:typedoc": "rimraf ./docs",
"docs:typedoc": "typedoc src --out docs",
},

We can generate JSDoc, same as before by running docs:jsdoc

Or, TypeDoc instead by running docs:typedoc

An example of this article can be found at GitHub from my NPM Package Boilerplate repository:

TypeScript is pretty awesome, but it’s not for everyone.

For NPM Packages, TypeScript is pretty slick in that its output can be leveraged in both JavaScript and TypeScript projects.

When using JavaScript, there’s no type declarations, meaning your NPM package isn’t as relevant to the TypeScript ecosystem.

But of course there’s a huge ecosystem of pure JavaScript packages at NPM. TypeScript developers must handle these situations. That aside, if you leverage JSDoc and relied on it for its type value, this hybrid solution just might satisfy both worlds.

Leave a Comment