Loris Sigrist looking very handsome Loris Sigrist

DTS-Buddy fixes Type-Declarations

dts-buddy is one of those tools that’s hard to justify without encountering the problem they solve first. Here’s the story of how I ran into it.

If you don’t care why, you can skip past the intro

A horror story about types in subpackages

I’ve been finding myself copying a lot of code between projects. To make this easier, I’ve been building a package where I keep all my commonly used code. Since it includes code from many domains, using subpackages seemed quite natural. @sigrist.dev/framework/pdf for all my PDF-related code, @sigrist.dev/framework/ui for all my UI-related code, and so on.

To keep editing convenient, I opted to use JSDoc types, and generate type-declarations from them.

While doing this, I quickly learned about the pitfalls of using subpackages. Using the TypeScript compiler means I was generating one d.ts file per js file. This caused a problems when importing a subpackage. Whenever I started typing import { the IDE would show me a list of all the types present in the package, including ones that were not meant to be public. This was very irritating.

Another issue I ran into is that go to definition didn’t work. I couldn’t jump to the implementation of a type, because the IDE didn’t know how to map the type-declaration to the actual source code. As the declarations were always colocated with the JS file it wasn’t that bad, but still inconveniet. I often have to glance at the implementation to recall what was going on, since the package isn’t documented well.

Dts-Buddy; The savior

dts-buddy solves all these things in a very clever way. Instead of colocating each type-declaration with the JS file it belongs to, it generates just one .d.ts file for the entire package. This is then referenced by the types field in your package.json. The file contains module declarations for the public interface of the package and it’s subpackages, using the declare module syntax.

Here’s an example output it generated for one of my (private, sorry) packages:

declare module '@sigrist.dev/framework/pdf' {
    /**
    * Adds a QR-ESR Invoice footer to the given PDF.
    * Assumes the current page has A4 portrait format.
    *
    * @see https://www.swiss-qr-invoice.org/validator/?lang=de for a validator
    */
    export function addQrEsrFooter(pdf: import("jspdf").jsPDF, data: ESRData) : import("jspdf").jsPDF;

    export type ESRData = {
        amount: number;
        reference: string;
        ...
    }
    ...
}

declare module '@sigrist.dev/framework/ui' {
  ...
}

Alongside this, it also generates a .map.d.ts file, which maps the public types onto the actual source code. This allows the IDE to “go to definition” and “peek definition” reliably.

How to use it

First install it:

pnpm i -D dts-buddy

Then create a build.js file in your project, and use it as your build script:

{
	"scripts": {
		"build": "node build.js"
	}
}
// build.js
import { createBundle } from 'dts-buddy';

//Generate a bundle of all type-declarations
await createBundle({
	project: 'tsconfig.json', //Your tsconfig.json

	//Map subpackages to their entrypoints
	modules: {
		'@sigrist.dev/framework/pdf': 'src/pdf/index.js',
		'@sigrist.dev/framework/ui': 'src/ui/index.js'
	},

	include: ['src'],

	output: 'types/index.d.ts' //The resulting type-declaration file
});

The only thing left to do is to tell the module-resolution to actually use the generated file. So, in your package.json, add a types field, and also register it in each exports field.

{
	"types": "types/index.d.ts", //here
	"exports": {
		".": {
			"types": "types/index.d.ts", //here
			"import": "./src/index.js"
		},
		"./pdf": {
			"types": "types/index.d.ts", //here
			"import": "./src/pdf/index.js"
		},
		"./ui": {
			"types": "types/index.d.ts", //here
			"import": "./src/ui/index.js"
		}
	}
}

That’s it! Now you can run npm run build and it’ll generate a single type-declaration file (+map) for your entire package.

Should you use it?

dts-buddy is a tool that solves the subpackage-problem very very well. Outside of that, the regular TypeScript compiler is good enough. It’s going to be more familiar to most developers and is maintained more actively. But when you do need dts-buddy, it’s a lifesaver.

I for one have really enjoyed it and am very likely to choose it again.