21
Extending various TypeScript type declarations
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.
Ever written code similar to the following?
function getData({tableName: process.env.TABLE_NAME as string})
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 */
};
}
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:
- Check what is the name of the module / interface / namespace I want to extend.
- Create corresponding
d.ts
file. Depending on what I'm doing I might be adding changes to a file that already exist. - 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;
}
}
That is it. We can safely remove the type assertion from previously shown piece of code.
function getData({tableName: process.env.TABLE_NAME})
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.
}
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 */
};
}
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
}
An Example usage:
// MyComponent.tsx
import { Component } from "lib-from-npm";
const MyComponent = () => {
return <Component />;
};
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
}
// MyComponent.tsx
import { Component } from "lib-from-npm"; // TypeScript complains. Read on to learn why.
const MyComponent = () => {
return <Component />;
};
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
}
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 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;
}
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.
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 ats
file, and both files live in the same directory, the type declarations defined in thed.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.
As per relevant GitHub issue discussion this should not be treated as a bug.
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.
21