FE / ts / js

Notes on making TypeScript declaration files

Notes on making TypeScript declaration files, a.k.a the .d.ts files for an npm package.
The case covered assumes multiple js files, each requiring a .d.ts.

General idea

Adding type declarations to an existing npm package can bring many benefits such as having text editors showing the function signature or tsc enforcing type check.

For these reasons and more, you may consider adding TypeScript declaration files, a.k.a the .d.ts file to the package.

The case

We’ll consider now a package with multiple js files, each accessible independently.

Assuming you have a similar situation:

.
└── src
    ├── folder
    │   └── file2.js
    └── file1.js

You have two options for having TypeScript declaration files:

  1. having the declaration files within the same folder of the js files
  2. or, have the types in another folder (Folder redirects)

Option 1: Types in the same folder

This method consists of having the types declaration files within the same folder of the js files.

No extra information in package.json required.
For each js file, create a .d.ts file.

// Example of types in the same folder
.
├── folder
│   ├── file2.d.ts
│   └── file2.js
├── file1.d.ts
└── file1.js

tsc filename mapping:
While skimming the code, I found in compiler/moduleSpecifiers.ts a comment that mentions some cases tsc cover to resolve the declaration files.

// Filename            | Relative Module Specifier Candidates         | Path Mapping                 | Filename Result    | Module Specifier Results
// --------------------<----------------------------------------------<------------------------------<-------------------||----------------------------
// dist/haha.d.ts      <- dist/haha, dist/haha.js                     <- "@app/*": ["./dist/*.d.ts"] <- @app/haha        || (none)
// dist/haha.d.ts      <- dist/haha, dist/haha.js                     <- "@app/*": ["./dist/*"]      <- (none)           || @app/haha, @app/haha.js
// dist/foo/index.d.ts <- dist/foo, dist/foo/index, dist/foo/index.js <- "@app/*": ["./dist/*.d.ts"] <- @app/foo/index   || (none)
// dist/foo/index.d.ts <- dist/foo, dist/foo/index, dist/foo/index.js <- "@app/*": ["./dist/*"]      <- (none)           || @app/foo, @app/foo/index, @app/foo/index.js
// dist/wow.js.js      <- dist/wow.js, dist/wow.js.js                 <- "@app/*": ["./dist/*.js"]   <- @app/wow.js      || @app/wow, @app/wow.js

Note: with this method, you can not provide different type declarations for different ts versions.

If you need to do so, use typesVersions.

Option 2: Types in a different folder

This method consists of having a separate folder for the types declaration files. You can have a folder for each ts version you want to support.

Once created the folder which will contain the types declarations, you can mirror the js folder structure.

.
├── types-folder
│   ├── folder
│   │   └── file2.d.ts
│   └── file1.d.ts
├── folder
│   └── file2.js
└── file1.js

In this case, types-folder mirrors the current package folder structure.

You’ll need the typesVersions property within package.json.

Specify the typesVersions property in package.json

Note: the main purpose of typesVersions is to allow different types for different ts versions.

This is technically called Folder redirects.

"typesVersions": {
    ">=3.1": { "*": ["types-folder/*"] }
},

Explanation

">=3.1" := typescript version

"*":                             ["types-folder/*"]
 ^                                   ^
 |                                   | 
 |

 For each file within this path: map it to the folder 

Automatically generate types for your js files

Especially if you have many files to cover, having some automatic tool can be handy.

tsc can generate some scaffolding for you using the emitDeclarationOnly flag.

# generate the TypeScript declaration files for all the js files and place them in ts-types
tsc ./**/*.js --declaration --allowJs --emitDeclarationOnly --outDir types-folder
  • --declaration : generate .d.ts files
  • --allowJs : allow javascript files to be imported
  • --emitDeclarationOnly : only emit (generate) .d.ts files; do not emit .js files.

Debug how TypeScript resolves types

In case tsc is not picking up the types you can try traceResolution for debugging.

tsc --noEmit --traceResolution
  • --noEmit : don’t produce any output
  • --traceResolution : show how ts resolve imports

Function overload

While writing a function signature in TypeScript, you may realise you need several signatures to cover the function written in js.

This is happening because as javascript is not a typed language, it may allow a succinct syntax that covers many types.

Depending on the case, you may need to use a function overload.

Function overload may be an edge case if you always working only on TypeScript, thus for reference here an example.

// Function overload example with export

declare function fn(x: HTMLDivElement): string;
declare function fn(x: HTMLElement): number;
declare function fn(x: unknown): unknown;

export default fn;

Summary

We saw two possibles strategies to add type declaration files to an existing npm package which are: 1) add types declaration within the same js folders (for each js file we have a .d.ts file) and 2) add types declaration within a different folder (in this case, we mirror the js folder within the types folder).

Then, we saw how to automatically generate declaration types from js files using tsc with the emitDeclarationOnly flag and how to debug the type import with the traceResolution flag.

With this mental model, you should be able to generate, add and debug type declaration files to an existing npm package.

While with the links provided, you should have enough reference points to the code and the documentation to explore the subject independently.