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 settingnoLib
compiler-option totrue
. 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 tonode_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 (usingexport
).
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.