Using Type Definition Files in a TypeScript Package

Use TypeScript, they said. It will be easy, they said. It will "just work", they said.

Using Type Definition Files in a TypeScript Package
Photo by Mildlee / Unsplash

I've been dabbling with TypeScript recently, because, well, 1. it's "the thing" to do nowadays, and 2. I've run into the limit of how far you can take JavaScript with just good documentation: after it reaches a certain level of complexity, it just... breaks down, and it's really difficult to maintain big projects without static typing.

So, I've been trying out TypeScript, building sample packages, sample repos, and now, a sample package in a TypeScript ESM Monorepo.

Here's the setup:

  • It's an npm monorepo,
  • which contains two "applications" in its workspace
  • and a shared package (that gets used by those applications),
  • with the entire repo using ES Modules (not relevant to this post) and TypeScript.

That shared package is a standard TypeScript package with a simple folder layout:

dist/
  index.js
  index.d.ts
  ...other files
src/
  index.ts
  ...other files
package.json
tsconfig.json

The tsconfig.json is pretty standard for a TS package:

{
  "include": ["./src"],
  "compilerOptions": {
    "outDir": "./dist",
    "declaration": true
    // ...other stuff
  }
}

To build the package, you run tsc to compile down all of the source code contained in src/ into dist/, bearing the same file and folder structure (i.e. no dist/src/index.js).

Then, it is exported by the package with the following package.json:

{
  "name": "package-name",
  "type": "module",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  // ...and so on
}

Simple, right?

Overriding Package Types

Within that shared package, there's a bit of code that imports stuff from the popular validator.js library:

import isURL from 'validator/es/lib/isURL.js'

Other than having to import from a nested path (it's just how this library supports ESM and tree shaking) and the .js extension (we're not importing from the package's main entrypoint, so we have to add .js extension for the module system to recognize the import), it's all pretty standard stuff, and it works well.

Except it doesn't have any types.

Nowadays you see many libraries bundling some sort of type definition files (ending with .d.ts) along with their exports, but sure, there are a lot that still don't. Now, if the package is even semi-popular, there are "type packages" (basically, packages with that missing .d.ts) that you can install to fill in the gap:

npm i @types/validator

Easy, right?

Except the types are wrong.

Sweet Jesus, why are the types wrong / it's like a top 100 npm package and people must download this package a lot / did they like just never notice that the types were completely wrong / did their IDE just not yell at them and put squigglies all over their code saying "hey your types are wrong dipshit"???

Okay, breathe.

Note: this is as of the time of this writing. It may be fixed later down the line.

At first, I thought it was wrong because I was importing from the nested path ('maybe the types are only defined for top-level exports?'), but even importing isURL from all the other alternate paths (instead of validator/es/lib/isURL.js) couldn't stop TypeScript from screaming at me that the isURL function is "not callable".

I've even tried to see if it's because I'm just importing it wrong (i.e. import isURL from ... vs. import { default as isURL } from ...), and tried out a bunch of different stuff, but it clearly didn't matter. TypeScript was picking out the right reference in the .d.ts file (i.e. when I ctrl+clicked the isURL import, it opened up the right type definition, defined in the @types/validator package), but it still didn't recognize the import as a function.

Wat?

Long story short, the @types/validator package doesn't work with ESM, and you have to do this to get the types to work:

import isURL from 'validator/lib/isURL.js'

isURL.default('hello') // WHY

But of course, while TypeScript may no longer complain, that is just straight up incorrect code. If you try to run this, it won't actually work, because there's no default property in that isURL.js ES Module (after all, I've already tried import { default as isURL } from ...).

Hoo boy.

So, okay. The types are wrong, you don't want to go through the hassle of submitting a PR in a giant repo that you're not familiar with (plus, if my experience with OSS is anything to go off of, I'm probably going to have to fight someone who thinks this is fine or that it's your fault for using ESM with TypeScript), but you still need your shit to work.

What do you do?

Why, just override the type definition for the module, of course!

This is as simple as dropping in a .d.ts file somewhere where tsc will pick up (i.e. within the folders specified in include in tsconfig.json, which is src/ in my case):

// src/types/validator.d.ts
declare module 'validator/es/lib/isURL.js' {
  export default function isURL(val: string, options: any): Boolean
}

Easy.

Your IDE will no longer complain, and neither will tsc. Now you're ready to compile and export your package!

So you run tsc, the files are now in dist/ (with the .js and their .d.ts files), and you import it from some application and... TypeScript complains that validator/es/lib/isURL.js has no types defined.

Wait, what? I thought we fixed this?

Exporting Type Definition Files With TypeScript

How come TypeScript complains about the type when you import the compiled package, even though it didn't when compiling the package?

If you look at what's actually generated (specifically, the files in dist/ that correspond to the TypeScript code in src/ that actually imports the validator package), you'll see that there's the import in the corresponding .js file... but no type definition file where we overrode the validator package's types.

So, src/types/validator.d.ts helped us "pass" the tsc compilation, but it didn't get included as part of our final build.

Oh, so we just need to include that file in the final dist/ bundle, just how hard can it be?

(I want to die)

I first tried the obvious thing, which was: "well, if the issue is with the type definition file not being included in dist/, then maybe I can just copy over the file over?"

So I tried copying over src/types/validator.d.ts into dist/. And of course, it didn't work. In particular, as the generated files in dist/ are just .js and their corresponding .d.ts files, you can't really slap on an extra .d.ts in a way that gets "picked up", without overwriting any of the existing .d.ts files and breaking the corresponding .js file!

Oh, and before some of you think, "well, why don't you just inline the type definition for the module within the TypeScript file?", here's why:

Okay, okay, fine. If you can't inline it, and if you can't just copy it over because it doesn't get referenced by anything, then what about 1. copying it over to the bundle, and 2. manually referencing it?

This is actually a good idea, and it almost works.

Here's what it looks like in practice:

// src/index.ts
/// <reference path="./types/validator.d.ts" />
// ...

With the reference, TypeScript pulls in the .d.ts file correctly during development, types the validator.js import correctly, and, once you copy it over after tsc, you can import the built package from another workspace in the monorepo without TypeScript complaining!

Hooray, that works, right?

Right?

Unfortunately, if you inspect the built package, you'll see something like this:

// dist/index.js
/// <reference path="../src/types/validator.d.ts" />
// ...

In other words, it's not actually including the copied over .d.ts file, nor is tsc referring to a .d.ts within the dist/ folder. It's referring to the file inside your src/ directory!

That's why it (almost) works: when you're importing the package from within the monorepo, TypeScript can just... go to the package/src/types/validator.d.ts and read it(!)

If that shared package is only going to be imported within the monorepo, that's fine... but if you intend to export that package for 3rd-party consumers, then the type needs to be included. They won't be able to resolve that ../src/types/validator.d.ts!

And that brings us to the final solution.

If the problem is with the referenced .d.ts file not being "bundled together" because it's pointing to its original location, then... why not have it outside the src/ directory and include that in the package (so that the reference to the .d.ts file can be resolved, whether you're referring to it from src/ or from dist/)?

In practice, it looks something like this:

dist/
  index.js
  index.d.ts
  ...other files
src/
  index.ts
  ...other files
types/
  validator.d.ts
package.json
tsconfig.json

And the package.json:

{
  "name": "package-name",
  "type": "module",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  // This is added:
  "files": [
    "dist",
    "typings"
  ],
  // ...and so on
}

No changes to tsconfig.json necessary!

Essentially, you reference the .d.ts file (within the types/ directory) from wherever you use the 3rd-party package import:

// src/index.ts
/// <reference path="../types/validator.d.ts" />
import isURL from 'validator/es/lib/isURL.js'

And then, once it's built, the compiled package will look like this:

// dist/index.js
/// <reference path="../types/validator.d.ts" />
import isURL from 'validator/es/lib/isURL.js'

Which, of course, points to the types/valdiator.d.ts that we include in the final package bundle. Hooray!

Conclusion

It's really unfortunate that there's no "official" TypeScript solution to this (note that our 'solution' was essentially hack together the TypeScript reference, JavaScript module resolution, and npm package exports to manually track, reference, and include the .d.ts file in the package).

In an ideal world, if you use a .d.ts file within your src/ folder (or wherever you instruct TypeScript to look for source files and typings), those should automatically be included and referenced in the final bundle.

Unfortunately, that is not the case, and you'll see many more shortcomings and failings when working with TypeScript - whether it's because of TypeScript itself, the vast, untyped/mistyped JavaScript ecosystem, or even ESM (which I suspect is why the @types/validator definition filed).

Things are constantly moving, and things are surely breaking. Hopefully this helped somebody to not tear out their hair.