Note To Self: Adding Type Declaration Files To A TypeScript 2.6.2 Project
The other day, I was talking to my teammate, Daniel Schwartz, about Type Declaration files in TypeScript. I've been using and loving TypeScript as part of my Angular 2 research and development; but, I am by no means a TypeScript expert. In fact, in the conversation that I was having, I didn't have much of any advice to offer on creating type declaration files. As such, I wanted to carve out a little time to get more familiar with them. And, for the most part, to provide some documentation for my future self who will, inevitably, forget how this all works.
The TypeScript site has a whole section on Type Declaration files; but, I have a lot of trouble connecting with concepts until I actually try using them for myself. So, for this post, I wanted to come up with different scenarios in which I have an existing .js file to work with; and, I need to provide an associated Type Declaration file in order to implement the type safety.
For all of the following demos, I am using the following npm run-script:
{
"scripts": {
"test": "rm -rf ./.tscache && ../node_modules/.bin/ts-node --cache-directory .tscache test.ts"
}
}
As you can see, I am deleting the TypeScript compiler cache before each run. This way, I make sure I get the least surprising and most consistent results with each execution. Otherwise, I found that the caching of compiled files hid some of the type validation errors, depending on which file was being modified.
Scenario One: Exporting A Default Function With Properties
In this scenario, we have an existing .js file that exports a default function. And, that function has properties stored on it:
// SCENARIO: This module exports a default function with properties.
module.exports = function getTime() {
return( Date.now() );
};
module.exports.EPOCH_TIME = 0;
module.exports.EPOCH_STRING = "1970-01-01T00:00:00Z";
For this "clock.js" file, I created a sibling file, "clock.d.ts":
export = getTime;
declare function getTime() : number;
// Merge the namespace properties into the getTime function declaration.
declare namespace getTime {
export declare var EPOCH_TIME: number;
export declare var EPOCH_STRING: string;
}
And, to test that everything was working, I ran this test.ts file:
import clock = require( "./clock" );
console.log( "Time:", clock() );
console.log( "EPOCH_TIME:", clock.EPOCH_TIME );
console.log( "EPOCH_STRING:", clock.EPOCH_STRING );
Now, in order to make sure that type-validation was truly taking place, I tried replacing the call to:
clock()
... with:
clock().toUpperCase()
... and, with that change, we get the following TypeScript compiler error:
Property 'toUpperCase' does not exist on type 'number'. (2339)
Scenario Two: Exporting A Default Class With Instance And Static Properties
In this scenario, we have an existing .js file that exports a default class. And, that class has an instance method and a static method:
// SCENARIO: This module exports a default class with instance and static methods.
module.exports = class Clock {
getTime() {
return( Date.now() );
}
static isClock( value ) {
return( value instanceof Clock );
}
};
For this "clock.js" file, I created a sibling file, "clock.d.ts":
export = Clock;
declare class Clock {
getTime() : number;
static isClock( value: any ) : boolean;
}
And, to test that everything was working, I ran this test.ts file:
import Clock = require( "./clock" );
consumeClock( new Clock() );
function consumeClock( clock: Clock ) : void {
console.log( "Time:", clock.getTime() );
console.log( "Is Clock:", Clock.isClock( clock ) );
}
Now, in order to make sure that type-validation was truly taking place, I tried replacing the call to:
consumeClock( new Clock() );
... with:
consumeClock( "blam" );
... and, with that change, we get the following TypeScript compiler error:
Argument of type '"blam"' is not assignable to parameter of type 'Clock'. (2345)
Scenario Three: Exporting Two Classes That Implement A Common Interface
In this scenario, we have an existing .js file that exports two classes that diverge but implement a common interface for getTime():
// SCENARIO: This module exports two classes that implement a common interface.
exports.WallClock = class WallClock {
getTime() {
return( Date.now() );
}
};
exports.StopClock = class StopClock {
constructor( initialTime = 0 ) {
this.time = initialTime;
}
getTime() {
return( this.time );
}
setTime( time ) {
this.time = time;
}
};
For this "clock.js" file, I created a sibling file, "clock.d.ts":
export interface Clock {
getTime() : number;
}
export declare class WallClock implements Clock {
getTime() : number;
}
export declare class StopClock implements Clock {
constructor( initialTime?: number );
getTime() : number;
setTime( time: number ) : void;
}
And, to test that everything was working, I ran this test.ts file:
import { Clock } from "./clock";
import { StopClock } from "./clock";
// Try to extend the declared class (make sure it works).
class MyClock extends StopClock implements Clock {
public getTime() : number {
return( super.getTime() * 2 );
}
}
consumeClock( new MyClock( 12345 ) );
function consumeClock( clock: Clock ) : void {
console.log( "Time:", clock.getTime() );
}
Now, in order to make sure that type-validation was truly taking place, I tried replacing the call to:
consumeClock( new MyClock( 12345 ) );
... with:
consumeClock( "blam" );
... and, with that change, we get the following TypeScript compiler error:
Argument of type '"blam"' is not assignable to parameter of type 'Clock'. (2345)
Scenario Four: A node_modules Module That Exports A Default Function
In the previous scenarios, the .js file was a file in the current project. This time, the .js file is a file installed in the node_modules directory via the "npm install" command in a folder labeled "clock":
// SCENARIO: This NODE_MODULES module exports a default function.
module.exports = function getTime() {
return( Date.now() );
};
For this "node_modules/clock/index.js" file, I created a declaration file, "clock.d.ts", in my application files. It doesn't really matter where this file is located as long as the TypeScript compiler can find it:
// The module name "clock" has to match the require() token.
// --
// NOTE: This declaration file can be stored anywhere in the TS project. The compiler
// will find it as long as it is not being explicitly ignored.
declare module "clock" {
export = getTime;
declare function getTime() : number;
}
And, to test that everything was working, I ran this test.ts file:
import clock = require( "clock" );
console.log( "Time:", clock() );
Now, in order to make sure that type-validation was truly taking place, I tried replacing the call to:
console.log( "Time:", clock() );
... with:
console.log( "Time:", clock().toUpperCase() );
... and, with that change, we get the following TypeScript compiler error:
Property 'toUpperCase' does not exist on type 'number'. (2339)
NOTE: If there is an "index.d.ts" file is in the node_modules module folder, alongside the module file, the TypeScript compiler will automatically pick it up. This allows JavaScript modules to easily ship with their own TypeScript declaration files.
As I was putting this all together, I did notice one somewhat strange behavior. As you can see in all of the above scenarios, the "export =" line stands on its own:
export = thing;
When the code is formatting like this, all of the type-checking works. However, if I tried to collapse this line with the type declaration line:
export = declare function thing() : number;
... then the TypeScript compiler compiles the code successfully; but, it doesn't actually perform type-validation on how the return value of thing() is being used. This leads to run-time errors instead of compile-time errors. I'm not sure if this is an intended behavior or a bug.
Hopefully there's not too much misinformation here. I ran all of this with TypeScript version 2.6.2 and ts-node version 3.3.0. And, again, I'm not a TypeScript expert - this was for my own benefit - your mileage my vary.
Want to use code from this post? Check out the license.
Reader Comments
It would be interesting to see if an auto .d.ts generator would give the same results for these .js files:
https://github.com/Microsoft/dts-gen
@Brian,
OOoh, very interesting. I have not seen that library, though I do remember reading something somewhere about auto-generation tools. I'll give it a go and see what happens.
@Brian,
I tried playing around with the dts-gen tool, but it looks like it doesn't really play well with arbitrary .js files. Either that or the documentation is very confusing. It looks like it's primarily geared towards .d.ts generation for modules (ie, things you've installed in the node_modules folder).
Where does typescript help us?
@Josh,
I hope we don't get into a "TypeScript vs JavaScript" discussion - there are are enough of those articles on Medium already:-)
@Josh,
For me, the biggest value-adds for TypeScript are:
1. It creates clarity around the code (as it illuminates what values are allowed).
2. It forces me to think about how the code can be consumed. Without types, it's very easy to make assumptions about how values will be passed-around and used by other developers. However, when Types are in play, the compiler will alert you that you are using an object in a way that it was intended to be used. This typically means I made an incorrect assumption somewhere.
3. It can be used to power dependency-injection (DI), especially in the Angular framework.
And, on top of that, it catches some types of bugs at compile time. But, to be honest, the bug-catching bits is a "nicety" - the real value-adds -- for me -- are the above.
@Ben
Is there a way to use these d.ts files to get code completion in the same js file without importing something? If for example i have a calc.js and a calc.d.ts . Is it possible to get codecompletion and type hints inside calc.js ?
I know that i can use JSDoc types to get type anotations and better code completion. But is there a way without polluting my original js file?
@Thomas,
Sorry my good man, I don't know. I'm not so good with all the IDE-level stuff. Though, to be honest, I barely understand how .d.ts files get tied into .js files. When it comes to TypeScript, I know only enough to be dangerous :D