DEV Community

loading...

Writing a TypeScript Type Definition for a JavaScript npm Package

Patrick Hund
Software engineer, cartoonist, electronic music producer. He/him.
Updated on ・4 min read

I'm building a social media network and collaboration tool based on mind maps, documenting my work in this series of blog posts. Follow me if you're interested in what I've learned along the way about building web apps with React, Tailwind CSS, Firebase, Apollo/GraphQL, three.js and TypeScript.

Rant Time

My honeymoon with TypeScript is over. I've spent much more time than I would care to admit to make it work properly with my 3D mind map demo, and I'm starting to wonder if it is really worth it.

What especially threw me off was creating a type definition file (.d.ts) for npm packages that don't have types.

The official documentation on this is quite lengthy, but in my opinion fails to explain the most basic things in a plain, easy to understand way.

Unfortunately, there are surprisingly few blog articles out there on the subject, and those few are mostly outdated.

OK, got that off my chest, let's move on…

Today's Goal

I'm using this npm package in my project:

This is a JavaScript only library, it does not provide any TypeScript type definitions, so I'll create my own.

Type Definition Files

My project is based on create-react-app. When you create a React app with --template typescript, you get everything set up for you to start using TypeScript right away.

Among other things, CRA creates a file react-app-env.d.ts in the source directory, with this content:

/// <reference types="react-scripts" />
Enter fullscreen mode Exit fullscreen mode

The weird reference statement includes a bunch of pre-defined types for the project to use. For example, this makes sure that you can import styles from CSS modules in TypeScript modules without the compiler complaining.

Files ending with .d.ts are called declaration files. For adding types for the THREE.Interactive library, I can add type declarations to the react-app-env.d.ts file, or I can create an additional file next to it, for example, three.interactive.d.ts.

All these declaration files are picked up by the TypeScript compiler automatically, I don't have to configure any paths for them to be included or anything like that.

Analyzing My Code

To see which types I have to declare, I take a look at the locations in my code where I'm using the library.

Here are the relevant lines, I've left out the code that doesn't have anything to do with THREE.Interactive:

initializeScene.ts

import { InteractionManager } from "three.interactive";

const interactionManager = new InteractionManager(renderer, camera, canvas);
Enter fullscreen mode Exit fullscreen mode

RenderCache.ts

import { InteractionManager } from 'three.interactive';

interface Constructor {
  interactionManager: InteractionManager;
}

export default class RenderCache {
  private interactionManager: InteractionManager;

  constructor({ interactionManager }: Constructor) {
    this.interactionManager = interactionManager;
  }

  preRender(data: MindMapData) {
    return Promise.all(
      data.nodes.map(async ({ name, val, id }) => {
        const sprite = await renderToSprite(
          <MindMapNode label={name} level={val} />
        );
        this.interactionManager.add(sprite);
      })
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

renderMindMap.ts

interactionManager.update();
Enter fullscreen mode Exit fullscreen mode

So this means I have to add a type declaration for a class InteractionManager, which I'm instantiating in initializeScene.ts.

I have to declare two methods:

  • add, which I'm using in RenderCache.ts
  • update, which I'm using in renderMindMap.ts

The library actually does more than this, but I decide to only declare types for the stuff I'm actually using.

Adding the Declarations

I'm adding the type declarations to react-app-env.d.ts so that I end up with this:

/// <reference types="react-scripts" />

declare module "three.interactive" {
  export class InteractionManager {
    constructor(
      renderer: THREE.Renderer,
      camera: THREE.Camera,
      canvas: HTMLCanvasElement
    );

    update(): void;

    add(object: THREE.Sprite): void;
  }
}
Enter fullscreen mode Exit fullscreen mode

WTF?

This works, the TypeScript compiler now makes sure I don't pass any illegal arguments to the constructor of InteractionManager or its update or add methods.

You may have noticed that my type declaration references types from the three.js library (THREE.Renderer, THREE.Camera and THREE.Sprite).

I thought I would have to import these types from three.js to make my type declaration work. Adding this to my react-app-env.d.ts seemed logical to me:

import * as THREE from 'three';
Enter fullscreen mode Exit fullscreen mode

When I did this, however, the compiler gave me this error:

Could not find a declaration file for module 'three.interactive'. '/node_modules/three.interactive/build/three.interactive.js' implicitly has an 'any' type.
Try npm install @types/three.interactive if it exists or add a new declaration (.d.ts) file containing `declare module 'three.interactive'

That's right – the compiler didn't tell me that there was something wrong my my import, it just ignored the .d.ts with the import altogether.

THREE for some reason is automagically already available, I guess as a global type, in my type declaration.

Check It Out

I don't have a CodeSandbox for you to try out this article's code, because CodeSandbox seems to not work properly with TypeScript, so I ditched it after a lot of frustrating trial and error.

Here's a repository on GitHub instead:

To Be Continued…

I'm planning to turn my mind map into a social media network and collaboration tool and will continue to blog about my progress in follow-up articles. Stay tuned!

Discussion (4)

Collapse
joshuatz profile image
Joshua Tzucker • Edited

The reason why adding an import to your *.d.ts file suddenly broke the code that relied on it is because adding imports or exports to a *.d.ts (or really, any *.ts file) changes it from behaving like a script to acting like a module. This is important because scripts have global scope, whereas modules are locally scoped.

If you want to use imports while still having your declarations be "ambient", you just need to wrap them in a declare global {} block.

PS: I feel your frustration with the complexity of ambient declaration docs. I have a cheatsheet, which you might find helpful as it even covers the issue above.

Collapse
pahund profile image
Patrick Hund Author

Cool, thanks for the input!

Collapse
noprod profile image
NOPROD ☄️ • Edited

Sometimes we can also directly look into the library's code, dev put somes exemples into the *.d.ts files, so you can follow it ^^

exemple in vue-next (runtime-core.d.ts) :

Collapse
pahund profile image
Patrick Hund Author

Definitely, that's the beauty of the JavaScript ecosystem, you can always inspect the source code