A quick introduction to Type Declaration files and adding type support to your JavaScript packages

In this lesson, we are going to take a closer look at type declaration files which are one of the key ingredients of TypeScript's vast type system. We will also learn to write custom type declarations for a JavaScript package.

A Type Declaration or Type Definition file is a TypeScript file but with .d.ts filename extension. So what so special about these Type Declaration files and how they are different from normal TypeScript files with .ts filename extensions?

A Type Declaration file, as the name suggests, only contains the type declarations and not the actual source code (business logic). These files are meant to only provide aid to the development process and not become part of the compilation itself.

A type declaration is just a declaration of a type such as an interface, a function or a class. You can declare a type and entity such as a variable, function, or an n object (that uses this type) at the same place (in the same file) or separate these in different files. We already went through the mechanisms to import these declarations in the previous lessons but here is the summary.

In the Module System lesson, we learned how to share type declaration between modules using import/export statements. You can import a type declaration from an ECMAScript or a CommonJS module just like a value.

In the Namespaces lesson, we learned how namespaces can help us achieve some modularity in a project without having to use fancy module systems such as ECMAScript or CommonJS. We can also share type declarations between different source files through interfaces using the triple-slash directives (explained in the namespaces lesson).

In the Compilation lesson, we learned about the lib compiler-option that controls the standard libraries included in the compilation process. These libraries are nothing but the declaration files provided by the TypeScript. We can also explicitly include type declarations in the compilation process using typeRoots and types compiler-options.

We can write our own TypeScript Declaration files or have them produced from the compilation of TypeScript files (.ts) by setting declaration compiler-option to true in the tsconfig.json file (or using--declaration flag with the tsc command).

In this lesson, we are going to learn the structure of the Type Declaration files and their use cases. Type Declaration in itself is a huge topic since the whole TypeScript ecosystem revolves around the Erased Type System (types), so we will keep this lesson as concise as possible. For more info on this topic, please visit the official TypeScript documentation website.


Global Type Declarations

A global type declaration is available everywhere all the time. For example, when you write new Promise but do not provide any arguments, the TypeScript compiler won’t compile the program since one function argument is required in the Promise constructor. So where this Promise class type is defined and how does the TypeScript compiler know about it?

As you can see from the above example, your IDE displays the error message and the location of the Type Declaration file that contains Promise type when you hover over the error. Such type declaration files are called global type declarations since they are exposed to every TypeScript program (included in the compilation) without having to explicitly import them.

The Promise type is written in one of the type declaration files provided by the TypeScript (lib.es2015.promise.d.ts to be precise, click on the blue link to open the file). They are collectively called the standard library.

💡 You can control which standard libraries are imported implicitly using the lib compiler-option. You can explicitly disable all standard libraries by setting noLib compiler-option to true. These options are explained in the Compilation lesson.

The standard library (global type declaration) files are imported implicitly by the TypeScript compiler by looking at the lib compiler-option (or target when lib is not provided).

To load some global type declaration files manually, we need to instruct the TypeScript compiler where to find them. These are done using typeRoots and types compiler-options. These are explained in detail in the Compilation lesson so I am just going to go straight to the example.

Let’s say in our project, we need to have a few common types that will be available to all the source files. The typeRoots option specifies the list of directories from where the types should be imported. When the value of this option is not specified in the tsconfig.json, the TypeScript compiler imports declarations from all the node_modules/@types directories using the Node’s module resolution algorithm.

The node_modules/@types is a default type-root directory. A type-root directory contains normal Node modules but designed only to provides only type declarations. This declaration module should have a index.d.ts in its parent directory which would be imported by TypeScript as an entry declaration file of the module and this file can later import other declaration files. Or this module should have the package.json that indicates the location of the entry declaration file inside the module.

/project/sample/
├── program.ts
├── tsconfig.json
└── types/
   └── common/
      ├── main.d.ts
      └── package.json

At the moment, my project structure looks like this. I have a tsconfig.json file in the root directory of the project and a types/ directory which will be my custom type-root directory. The common declaration module contains the main.d.ts declaration file and a standard package.json.

Since the common declaration module doesn’t contain index.d.ts, we need to indicate the path of the main.d.ts (which is our entry declaration file) using the types or typings field of the package.json as shown below.

// types/common/package.json
{
  "name": "common",
  "version": "1.0.0",
  "typings": "main.d.ts"
}

Though we have a type declaration module, TypeScript has no idea that it exists since we haven’t configured the typeRoots compiler-option.

// tsconfig.json
{
  "files": ["./program.ts"],
  "compilerOptions": {
    "typeRoots": ["./types"]
  }
}

In the above tsconfig.json, we have specified the value for the typeRoots compiler-option and it points to the types/ directory relative to this file. I would recommend to also provide the node_modules/@types path as a type-root in case you want to install and use third-party declaration packages.

Now that our common custom declaration package is ready to be used, let’s put some type declarations inside main.d.ts.

// types/common/main.d.ts
interface Person {
  firstName: string;
  lastName: string;
  age: number;
}

We have declared the Person interface in the main.d.ts file which would make it available to all the TypeScript files in the project. Let’s try to use it inside the common-test.ts program file (source).

As you can see from the above program, we have annotated ross as a type of Person and TypeScript would allow it since Person type is globally imported by the TypeScript from the common declaration package. If we try to compile the program, we would get the error since the age property is missing from the object. TypeScript and the IDE also show the location of the declaration file where Person declaration is located.

Modularizing Declarations

In the above example, TypeScript only knows about main.d.ts and it will import all the declarations from the file. However, if your project needs hundreds or thousands of types, then putting these declarations in a single file is not such a good idea.

In the Namespaces lesson, we learned how the TypeScript compiler uses the tripple-slash directive <reference /> to include files in the compilation process. We can use the same directive inside main.d.ts to include other type declaration files. Let’s modify the declaration package structure first.

/project/sample/
├── program.ts
├── tsconfig.json
└── types/
   └── common/
      ├── main.d.ts
      ├── interfaces.d.ts
      ├── functions.d.ts
      └── package.json

The interfaces.d.ts contains the type declarations of all the global interfaces provided by the common package and functions.d.ts provides the function type declarations. Now, we need to reference these files inside main.d.ts so that TypeScript can also include them along with main.d.ts.

// types/common/main.d.ts
/// <reference path="./interfaces.d.ts" />
/// <reference path="./functions.d.ts" />

// types/common/interfaces.d.ts
interface Person {
  firstName: string;
  lastName: string;
  age: number;
}

// types/common/functions.d.ts
type GetFullName = (p: Person) => string;

Now, we are using main.d.ts purely to import other declaration files but you can add declarations in it if you want. Notice how the Person interface from the interface.d.ts is available inside function.d.ts. This is possible since the reference directives make values and types between files pointed by path attribute sharable (their order doesn’t matter).

As you can see from the above program, we now have access to the GetFullName function types as well as Person interface type. You can click on these types (holding the Common key) to jump to their declaration files.

Third-Party Declarations

So far, we have learned how to create custom type declaration files and the mechanism to make them available globally to a TypeScript project. However, in some situations, the type declarations will be provided by a third-party provider using an NPM package.

This is common among libraries such as lodash which are not written in TypeScript originally or node which is a JavaScript runtime that presents its own global JavaScript APIs such as console.log, require, and process as well as standard library modules such as fs, path, etc.

The DefinitelyTyped community writes declaration packages for such third-party libraries and platforms. These declaration packages are published under @[types](https://www.npmjs.com/org/types) NPM organization that’s why you need to install them using the command similar to below.

$ npm install -D @types/node

This will install the node declaration package inside node_modules/@types directory, so the lodash declaration files will be imported from the path node_modules/@types/node/ relative to the tsconfig.json file inside the project. This package contains index.d.ts as an entry declaration file.

💡 This explains why the default value of typeRoots compiler-option is set to node_modules/@type path.

/project/sample/
├── program.ts
├── package.json
├── tsconfig.json
├── node_modules
|  └── @types/
|     └── node
└── types
   └── common/

Now that we have two type-root directories, we need to specify them inside the tsconfig.json file. If you are wondering why TypeScript can’t merge the custom typeRoots values with the default value, the issue is open here.

// tsconfig.json
{
  "files": ["./program.ts"],
  "compilerOptions": {
    "lib": ["ES2015"],
    "typeRoots": ["./node_modules/@types", "./types"]
  }
}

The lib option in the above tsconfig.json only imports ES2015 JavaScript specifications and not other libraries like DOM which is imported by default that contains Web APIs of the browser such as console.log.

In the above example, the Type Declarations for the console value will be imported from the @types/node package. You can click on the console or the log value to see their definitions or jump to their definition files.

Ambient declarations

An ambient value is a value that can exist only at the runtime. For example, window object can only exist when your program is running in the browser, the same goes for the global value in the case of Node.

When you use window or global as a value in a TypeScript program, the TypeScript compiler complains cannot find name 'window'. which is fine because we haven’t defined a variable named window. So then how can we possibly write window.onload = ... if window is not defined?

This is where we need to tell the TypeScript compiler that the window value exists (perhaps at runtime) and it should not complain about it. For that, we need to use declare keyword as shown below.

declare var window: any;

This statement is called ambient declaration and this is an ambient declaration for the window variable. TypeScript looks at this declaration and assumes that the variable window of the type any has been defined somewhere so do not complain when window value is used anywhere in the program.

window.version = '1.0.0';

We can perform any operations on the window such as assigning a property version as shown above since the type of window is any. You can also provide any given type to an ambient value such as declare var window: Person;

The window object provided by the browser is quite big and writing a custom type that describes its API is just wrong. That’s why TypeScript provides its ambient declaration through the DOM standard library. Here is how this declaration looks like (declare var window: Window).

In the previous example, when we installed @types/node declaration package which made the console.log function available, now you know how that was done. You can check the ambient declaration of the console from here.

We can write ambient declarations in the source programs but let’s provide an ambient declaration from the declaration package just for our satisfaction.

// program.ts
console.log(version);

In the above program, we are logging the value version that hasn’t been defined anywhere in the program. When we compile this program, the error is pretty evident (shown below).

$ tsc
program.ts:1:14 - error TS2304: Cannot find name 'version'.
console.log( version );
             ~~~~~~~

If version value will be available at runtime, then we must write an ambient declaration for it. If you need to provide this declaration globally, then we should write this in the types/common declaration package.

// types/common/main.d.ts
/// <reference path="./interfaces.d.ts" />
/// <reference path="./functions.d.ts" />
declare var version: string;

You can also add ambient declarations in the files referenced by the reference directives but here in our case, we have added directly inside the entry declaration file. Now, the program will compile just fine.

Namespaced Declarations

In the previous example, we have written a types/common declaration package that provides Person interface type globally. This is great if you need this type in almost all the source files. However, the Person type doesn’t sound so unique, it can be accidentally overridden by another declaration package easily or it can do damage to other packages since its global.

To avoid this, it is generally considered good practice to namespace your type declarations. In the namespaces lesson, we learned how we can write type declarations in the namespace. Let’s namespace our type declarations.

// types/common/main.d.ts
/// <reference path="./interfaces.d.ts" />
/// <reference path="./functions.d.ts" />

// types/common/interfaces.d.ts
declare namespace common {
  interface Person {
    firstName: string;
    lastName: string;
    age: number;
  }
}

// types/common/functions.d.ts
declare namespace common {
  type GetFullName = (p: Person) => string;
}

Now there are few differences here from what we have learned in the namespaces lesson. First of all, a namespace is a value, hence it is mandatory to declare a namespace as an ambient value in a .d.ts file. This will make common namespace available globally and we can access common.<type>.

💡 The reason we need to add declare keyword while declaring a namespace is that the declaration files (.d.ts) can only contain types and not values. Since namespace is a value, we can’t declare it inside a declaration file but we can write an abient declaration of it that contains only types.

When a namespace is specified in a type declaration file (.d.ts), the export keyword becomes redundant. Every type inside a namespace is exported implicitly. Also, you can’t export a value from the namespace, only types are allowed. That means these namespaces can only be used for type annotations so that they won’t leak into the compiled JavaScript code.

Since Person or GetFullName types are not exposed globally anymore, we need to modify our source code. In the above program, common namespace is exposed globally and it exports Person and GetFullName, therefore we have accessed these types using the common.<type> syntax.

Extending Declarations

In the Interfaces lesson, we saw that when multiple interfaces with the same name are declared in the same module (file), TypeScript coerces them into one declaration by merging their properties together.

Similarly in the namespaces lesson, we learned that by declaring a namespace that already exists, TypeScript extends the previously declared namespace with new exported values, exactly like how namespaces behave. The same goes for the interfaces. When an interface or a namespace is available globally, it can be extended by redeclaring it in a program.

// types/common/main.d.ts
/// <reference path="./interfaces.d.ts" />
/// <reference path="./functions.d.ts" />

// types/common/interfaces.d.ts
interface Person {
  firstName: string;
  lastName: string;
  age: number;
}

// types/common/functions.d.ts
declare namespace common {
  type GetFullName = (p: Person) => string;
}

In the above case, the Person interface is available globally. If we want to add any properties to it, we can just redeclare it in the program.

In the above example, we have redeclared the Person interface and specified the email property of the type string. This will result in the declaration of Person interface that has the properties of the global Person interface and local Person interface.

Similarly Number is built-in JavaScript type and it is declared as an interface in the lib.es5.d.ts file of the standard library. By redeclaring it, we are adding isEven method to the interface. This means that Number type is assumed to have the isEven method at runtime (though we do not have any implementation of it at the moment, so this program won’t run).

However, this doesn’t work in the case of modules. In the Script vs Module section of the Type System lesson, we saw that the values and types can only be shared between script files (the ones that do not contain import or export statement) included in the compilation process.

We also saw that the custom values in the global scope populated by a module are not accessible elsewhere since this information is not communicated by any means. The same things happen with the global type declarations.

In the above example, we have converted program.ts to a module just by placing an export {} statement at the very end. In this case, TypeScript doesn’t extend the global types but redeclares them in the current module.

You can compare this with a global variable. When you declare a variable inside a normal script file, it overrides the previously declared variable in the global scope if one exists with the same name, else it results in the creation of a new global variable. However, when you declare the same variable in a module, it creates a new variable in the module scope and the global variable remains untouched. The same principle applies to the type declarations.

Therefore in the above example, the Person and Number interfaces got redeclared in the module scope. Since the Person interface here lacks firstName property, TypeScript will complain about it. Similarly, the Number interface couldn’t add the isEven method to the global Number type that is used by the number primitive type but it got redeclared in the module.

To fix this, TypeScript provides declare global ambient declaration statement that acts as a namespace. Using this, we can add declarations to the global scope from within a module.

In the above example, we have added Person and Number interface to the global scope. Since these interfaces were already defined in the global scope, they will get extended with the new properties (exactly like how a script file behaves). Any TypeScript program can now access amended Person and the Number interface from the global scope.

Similarly, the global scope now has the version variable declaration. Therefore any TypeScript program can access this variable or assign value to it. The global keyword points to an implicitly defined namespace by TypeScript that contains all the global values including window in the case of browser or process in the case of Node.js.

Third-Party Package Declarations

In most of the situations, we are always working alongside third-party ECMAScript or CommonJS modules. We install these modules generally using the npm install command which installs the module inside node_modules directory. You later import this module inside a program using an import statement. We discussed this process from the Module System lesson.

In the previous sections, we talked about the DefinitelyTyped community. This community publishes declaration packages of the third-party JavaScript libraries. Most of these libraries are modules that are available through NPM.

When you install lodash package using the command npm install lodash, what you get is a Node module that is written in vanilla JavaScript. Therefore, when you import this module inside a TypeScript program using import 'lodash' import statement, though TypeScript can locate the package inside node_modules directory but it can’t provide types for it.

All we need is the type declarations for this package. This can be provided by the @types/lodash package similar to how the @types/node package provides the declarations for Node.js APIs. The difference here is that the @types/node declaration package adds type declarations in the global scope so that it can be available to all the source files but the @types/lodash declaration package only provides types for the imports of the lodash package.

Let’s install the lodash package first and implement the _.toUpper function in the sample program. Here is a link for the API of the toUpper function.

In the above program, we have imported all the functions exported by the lodash package inside _ value. Therefore, we can call the _.toUpper function with an argument. The first argument of the toUpper function must be a string so that this function can return it in uppercase format.

However, in the above example, the first argument to the toUpper function is a type of boolean which is not valid but TypeScript doesn’t complain about it since the type of toUpper function is any by default.

This is happening since TypeScript doesn’t know anything about the API structure of the lodash as this package is neither written in TypeScript nor it provides type declarations using the typings (or types) field of its package.json. So TypeScript will simply ignore type checking for it.

All we need to do is to provide type declarations for it using a declaration package. This is where @types/lodash declaration package becomes a lifesaver. Let’s install this package using npm install command and see how our program behaves (you might need to reload your IDE after installation).

Now you can see that the TypeScript compiler doesn’t allow true argument value for the _.toUpper function call since it knows the type of the toUpper function from the @types/lodash declaration package.

This is all well and good but how these declarations work for a package? Let’s understand how we can write custom declarations for a package.

Custom Package Declarations

Let’s set up simple JavaScript packages in a local directory.

/projects/sample
├── tsconfig.json
├── person-test.ts
├── human-test.ts
├── types/
└── packages
   ├── human
   |  ├── human.js
   |  └── package.json
   └── person
      ├── person.d.ts
      ├── package.json
      └── person.js

As shown in the above directory structure, the packages directory contains the person and the human package. The person-test.ts imports the person package and human-test.ts imports the human package.

// tsconfig.json
{
  "files": ["./person-test.ts", "./human-test.ts"],
  "compilerOptions": {
    "baseUrl": "./packages",
    "outDir": "dist",
    "typeRoots": ["./types"]
  }
}

The tsconfig.json for this project looks like this. You should read the Compilation lesson to understand what baseUrl compiler-option does but in a nutshell, it is used by the TypeScript compiler to look for the imported packages if a package is not found inside node_modules directory.

Now that we have the project structure ready, let’s first understand how the person package looks like and how it is being implemented inside person-test.ts program.

The person package contains the package.json file since an NPM package needs to declare the package version, dependencies, and other meta-data related to the package. This file looks like the following.

// person/package.json
{
  "name": "person",
  "version": "1.0.0",
  "main": "./person.js",
  "typings": "./person.d.ts"
}

The main field points to a JavaScript file in the package directory that will be used by the runtime to import the values from whenever it encounters the import 'person' statement in the program. This file is used by the TypeScript program to analyze the API of the package if allowJs compiler-option is set to true, else it will allow the import of any value as seen in the previous section. But there is a better way to do this.

What TypeScript needs is the type declaration of the package. This is done using many methods that we have discussed in the Module System lesson one of which is by using the typings field of the package.json.

The typings (or types) field points to the declaration file (.d.ts) that will be used by the TypeScript compiler to understand the API of the package instead of the main file. Let’s export some values from the person.js and use the person.d.ts to provide the declaration of these values.

As we have learned, a type declaration can contain only the declarations and not actual values or the implementations. For that reason, we need to strip off all the value initializations and implementations from the person.d.ts.

As you can see from the person.d.ts file above, the class Person only contains declarations of static properties, constructor method and other methods without any initial values or method implementations. The same goes for version variable and getFullName function.

When a package exports a value or a type, we need to explicitly add export statement for it in the declaration file. The flavor of export statements is the same as any module.

A type declaration file is also a TypeScript program that can only contain declarations. Therefore you can import types from other TypeScript programs (both .ts and .d.ts) in this file using import statements.

When we import the person package inside person-test.ts program as shown above, TypeScript provides full type support for the imports of the package. If you wrongfully implement any import of this package, the TypeScript compiler will display the exact error message.

This was the example of a package which is under your full autonomy meaning you could alter the package.json and add person.d.ts file in the package. If you wish to publish this package, whoever installs it will receive the full type support through to the magic of person.d.ts.

However, in some cases, you do not own a third-party JavaScript package that does not provide type support but you still want to add type declarations for it from the outside. This is the exact problem DefinitelyTyped has solved.

Let’s use the human package for this purpose. The human package doesn’t contain a type declaration file and its package.json only contains the references to the entry point of the package.

// human/package.json
{
  "name": "human",
  "version": "1.0.0",
  "main": "./human.js"
}

The human.js file contains the API of this package and for simplicity, let’s reuse the implementation of person.js program.

Now if we import this package inside human-test.ts, TypeScript would not provide any type support for it since this package doesn’t provide any type declarations as shown below. Every single imported value will have the default type of any.

What we need to do is to write a declaration package (just like we wrote for global declarations) that provides declarations for this package.

Let’s create a package human-types inside types/ directory since it is added inside typeRoots of the tsconfig.json. This time, let’s create the declaration file with the name index.d.ts inside it so that we won’t need the package.json file to point to this declaration file.

The declare module "<module-name>" block declares an ambient module which means that this module will exist at the runtime. The syntax to declare a module and export values, as well as the types, is similar to a namespace declaration in a normal TypeScript program file. If the same module is declared twice, their export values are merged together just like namespaces.

Local Type Declarations

In the Module System lesson, we learned that a type can also be exchanged between two modules using simple import and export statements. However, we have learned that the TypeScript compiler also looks for .d.ts file while looking for an imported file. For example, import './some' statement would result in the lookup of ./some.ts and ./some.d.ts (also ./some.js if allowJs compiler-option is set to true).

A type declaration file can only contain type declarations and nothing else, unlike a normal TypeScript (.ts) program that contains both value and types. Besides that, TypeScript and Type Declaration files are the same. Therefore you can export types from the declaration file as usual.

You can then import these types from the type declaration file using import statement. After compilation, TypeScript removes the import statement of all declaration files since a type declaration import doesn’t contain a value that will be useful at the runtime.

// local-declaration.d.ts
export interface Person {
  firstName: string;
  lastName: string;
  age: number;
}

// local-declaration-test.ts
import { Person } from "./local-declaration";
const ross: Person = {
  firstName: "Ross",
  lastName: "Geller",
  age: 29,
};

In the above example, we have created the local-declaration.d.ts file that contains some types that will be useful to some parts of our project. We did not make it global since it could be useful to just one source file else it could create some unwanted side effects if done otherwise.

The local-declaration-test.ts program imports this file and extracts Person interface type. When we compile this program, the output JavaScript code looks below.

// local-declaration-test.js
"use strict";
exports.__esModule = true;
var ross = {
  firstName: "Ross",
  lastName: "Geller",
  age: 29,
};

As we can see from the above output, the import statement of the declaration file has been removed from the output but if there were any other normal import statements, it would have been there.

The second most important thing is that this method do not generate declaration files in the output directory when declaration compiler-option is set to true. That means you have to manually migrate imported declaration files in the output directory.

I would personally not prefer to use these local declaration files since they needlessly introduce module system in the program.

💡 You can use the triple-slash reference directive to refer to a .d.ts file from within a .ts file. But in this case, .d.ts file must declare the types in the global scope and not export them (using export).


The API of your library depends on the structure of the compiled output. There are many formats to publish your library in. The most popular seems to be UMD that can work both in the browser (when imported using the <script> tag) and in Node (using both CommonJS and ECMAScript module systems).

This TypeScript documentation contains some useful information about the library structure. If you want to publish a library as an NPM package, then this documentation contains some templates based on what kind of library structure you want to use.

#typescript #type-declarations