Extending various TypeScript type declarations

Wojciech Matuszewski - Jun 26 '21 - - Dev Community

Working with TypeScript can be a blissful experience – the type completions, the fast feedback loop, and the confidence gained by the presence of types make up for a great DX.

But yet, sometimes, these experiences are interrupted by moments of frustration. For example, maybe the library you have just pulled from npm does not expose type declarations? Or perhaps TypeScript is not aware of a global variable you know exists?

If that describes your experiences, read on. The following contains tips regarding extending TypeScript type declarations. I believe that by following them, the number of frustrations you experience while working with TypeScript will drastically decrease.

Extending global type declarations

Ever written code similar to the following?

function getData({tableName: process.env.TABLE_NAME as string})
Enter fullscreen mode Exit fullscreen mode

What about this?

/**
 * By default, TypeScript is not aware of the `Cypress` global variable available whenever the code is run in the context of a Cypress test.
 * If we do not amend the global type declarations, the error has to be silenced.
 */
// @ts-expect-error
if (window.Cypress) {
  window.myAPI = {
    /* implementation */
  };
}
Enter fullscreen mode Exit fullscreen mode

While not a big deal, having to use type assertions in these kinds of situations is not fun. Would it not be nice to have our environment variables strongly typed? Or that Cypress global whenever your code is run in the context of a Cypress test?

By augmenting global type declarations, we can make sure that these, and similar, problems go away. Type assertions no longer clutter our code, and the TypeScript compiler is happy. Whenever I need to extend any type declarations, I follow these steps:

  1. Check what is the name of the module / interface / namespace I want to extend.
  2. Create corresponding d.ts file. Depending on what I'm doing I might be adding changes to a file that already exist.
  3. Augment the module / interface / namespace.

Let us start with the first problem - extending process.env type declarations to include our custom environment variables.

Check what is the name of the module / interface / namespace I want to extend.

By hovering on process.env I can see that the .env property lives on a namespace called NodeJS. The .env property is described by an interface called ProcessEnv.

Create corresponding d.ts file. Depending on what I'm doing I might be adding changes to a file that already exist.

Since I'm augmenting global type declarations, I will create a file called global.d.ts. Please note that I've chosen the d.ts file extension on purpose. It signals to my colleges that this file only contains type declarations.

Augment the module / interface / namespace.

Since the .env property lives on a namespace called NodeJS, I'm going to follow the merging namespaces guide from the typescript handbook.

// global.d.ts
namespace NodeJS {
  interface ProcessEnv {
    TABLE_NAME: string;
  }
}
Enter fullscreen mode Exit fullscreen mode

That is it. We can safely remove the type assertion from previously shown piece of code.

function getData({tableName: process.env.TABLE_NAME})
Enter fullscreen mode Exit fullscreen mode

Let us turn our attention to the second example - extending the window typings so that it includes the Cypress property.
The window global variable is annotated by Window interface and the typeof globalThis. Let us amend the Window interface since it's easier to do so.

// global.d.ts
interface Window {
  Cypress?: unknown; // Depending on your use-case you might want to be more precise here.
}
Enter fullscreen mode Exit fullscreen mode

Since interfaces are always extendable that is all, we have to do. Whenever TypeScript loads the global.d.ts file, the Window interface from the built-in type declarations will be extended with our custom Window interface.

With that, gone is the nasty @ts-expect-error comment.

if (window.Cypress) {
  window.myAPI = {
    /* implementation */
  };
}
Enter fullscreen mode Exit fullscreen mode

Declaring type declarations for a 3rd party library

What if the new shiny library you have just pulled from the npm does not come with type declarations?

In such situations, the next thing we could do is to try to pull the types for that library from the collection of community maintained types called DefinitelyTyped. But, unfortunately, while in most cases, the type declarations that we are looking for already exist there, it's not always the case. So what should we do then?

Thankfully, the missing typings can be defined manually. To do so, I usually reach out for global module augmentation technique that we have used earlier (the three steps process still applies to some extend).

Here is an example of adding type declarations for a library called lib-from-npm. The library in question exposes a Component function that renders a React component:

// lib-from-npm.d.ts
declare module "lib-from-npm" {
    interface Props {
        // ...
    }

    function Component (props: Props) => import("React").ReactNode
}
Enter fullscreen mode Exit fullscreen mode

An Example usage:

// MyComponent.tsx
import { Component } from "lib-from-npm";

const MyComponent = () => {
  return <Component />;
};
Enter fullscreen mode Exit fullscreen mode

You might be wondering what the import("React") statement is about. What about importing the ReactNode using import {ReactNode} from 'react'?

Let us find out what happens if I do that.

// lib-from-npm.d.ts
import { ReactNode } from 'react'

declare module "lib-from-npm" {
    interface Props {
        // ...
    }

    function Component (props: Props) => ReactNode
}
Enter fullscreen mode Exit fullscreen mode
// MyComponent.tsx
import { Component } from "lib-from-npm"; // TypeScript complains. Read on to learn why.

const MyComponent = () => {
  return <Component />;
};
Enter fullscreen mode Exit fullscreen mode

I'm left with Cannot find module 'lib-from-npm' or its corresponding type declarations TypeScript error. It seems like the type of declarations I've just written does not work, how come?

Whenever the TypeScript file you are working with contains a top-level import statement(s), TypeScript will treat this file as a module. If a file is treated as a module, type declarations within that file are contained to that file only.

This is why I've used the import("React") statement in the first snippet. Introduced in TypeScript 2.9, the import types feature allows me to explicitly import only type declarations for a given module without using a top-level import statement. You can read more about this feature in this excellent blog post.

Having said that, this is not the only way of safely (without making TypeScript treat the definition file as a module) way of importing types to the lib-from-npm.d.ts file.

Here are the alternatives I'm aware of:

// lib-from-npm.d.ts

declare module "lib-from-npm" {
    import { ReactNode } from 'react'

    // Or to be even more specific
    // import type { ReactNode } from 'react';

    interface Props {
        // ...
    }

    function Component (props: Props) => ReactNode
}
Enter fullscreen mode Exit fullscreen mode

Both alternatives work because the import statement lives in the scope of a lib-from-npm module. There are no top-level import(s) statements that would make this file be treated as a module by TypeScript compiler.

Extending types of a 3rd party library

Extending types of a 3rd party library is usually no different than extending any global type declaration. The three-step process defined in the Extending global type declarations section still applies.

For example, let us say that we want to add the createRoot API to the ReactDOM typings. The createRoot API is related to the concurrent rendering the React 18 plans to introduce. Please note that the typings for the alpha release of React 18 already exist and should be preferred instead of rolling your own.

Since the render API of the ReactDOM package is defined within the ReactDOM namespace, let us extend that namespace with the createRoot API.

// react.d.ts
namespace ReactDOM {
  import * as React from "react";

  interface Root {
    render(children: React.ReactChild | React.ReactNodeArray): void;
    unmount(): void;
  }

  function createRoot(
    container: Element | Document | DocumentFragment | Comment
  ): Root;
}
Enter fullscreen mode Exit fullscreen mode

As you can see I'm sticking to the principles of augmenting 3rd party library type declarations that I've defined in the previous section.
There are no top-level import(s) statements to make sure this file is not treated as module by the TypeScript compiler.

Landmine

The location and the name of your d.ts files matters. In some unfortunate circumstances, it might happen that your d.ts file will be ignored.
I encountered this problem a while back, and it has stuck with me ever since. Here is the gotcha I'm talking about:

Whenever your d.ts file has the same name as a ts file, and both files live in the same directory, the type declarations defined in the d.ts file will be ignored.

This means that going back to the previous section, If I were to create a file named react.ts in the same directory that the react.d.ts file lives, the type declarations defined in the react.d.ts file would be ignored.

// react.ts
import ReactDOM from "react-dom";

ReactDOM.createRoot(); // TypeScript complains.
Enter fullscreen mode Exit fullscreen mode

As per relevant GitHub issue discussion this should not be treated as a bug.

Summary

I hope that the material presented here will help you in your day-to-day adventures with TypeScript.
The npm ecosystem is vast, and undoubtedly, one day, you will encounter a package that does not have type declarations defined for it. Whenever that moment occurs, remember about the three steps I talked about - they should help you get going with the library in no time.

You can find me on twitter - @wm_matuszewski

Thank you for your time.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .