Skip to main content
Ben Nadel at InVision In Real Life (IRL) 2018 (Hollywood, CA) with: Aaron Lerch
Ben Nadel at InVision In Real Life (IRL) 2018 (Hollywood, CA) with: Aaron Lerch

I Love That TypeScript Doesn't Allow Me To Make Incorrect Assumptions About Object Usage

By
Published in Comments (4)

I started using TypeScript as part of my Angular 2 research and development (R&D). There's definitely a learning-curve to TypeScript - one that I am very much still on; but, I think it's fair to say that I loved TypeScript from day-one. Of the many features that TypeScript provides, one of the features that I have found most beneficial is that TypeScript prevents me from making poor assumptions about how objects will be used and consumed in my code. Like native JavaScript, TypeScript allows for "duck typing"; but, the compile-time type checker actually enforces "duck typing", which will quickly shine a light on all the poor assumptions that you've made about what it means to walk and talk and quack like a duck.

One of the first times that I ran into this TypeScript feature was when I was trying to create a wrapper class that would proxy calls to a target object, decorating them in some way. For example, given an API client, I might want to create a wrapper for the client that adds timing instrumentation around each method call. In native JavaScript, such a wrapper might look like this:

// Require the core node modules.
var chalk = require( "chalk" );

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

// I provide a set of methods of interacting with an API.
class API {

	constructor( url ) {

		this.url = url;

	}

	// ---
	// PUBLIC METHODS.
	// ---

	async get() {

		console.log( chalk.cyan( "Making request to:", this.url ) );

		return( await Promise.resolve() );

	}

}

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

// I implement the API interface, but add timing instrumentation for the methods.
class InstrumentedAPI {

	constructor( api ) {

		this.api = api;

	}

	// ---
	// PUBLIC METHODS.
	// ---

	async get() {

		var startedAt = Date.now();
		var promise = this.api.get();

		var logHandler = /*function*/() => {

			console.log( chalk.grey( `... get() took ${ Date.now() - startedAt }ms.` ) );

		};

		promise.then( logHandler, logHandler );

		return( promise );

	}

}

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

function main( api ) {

	api.get();

}

// Notice that the version of the API we're passing into the main function is actually
// the instrumented / wrapped version. This will allow us to see the timing of all the
// API functions.
main(
	new InstrumentedAPI(
		new API( "http://my-fancy-api.coolbeans" )
	)
);

As you can see, I have a simple API class that provides a get() method. I then have an InstrumentedAPI class that exposes the same method, but wraps console-logging around the underlying get() method invocation. I then pass an instance of InstrumentedAPI to the main method - which is expecting an instance of API. And, this all works quite nicely:

Duck typing workes nicely in JavaScript, provided you make the right assumptions.

As you can see, the main() method consumes the InstrumentedAPI as if it were the API. And, as a result, we get both the underlying method call as well as the console-logging instrumentation.

This works because the InstrumentedAPI class walks and talks and quacks like the API class. At least mostly. In this simple case, we're not really putting that theory to the test because we're only exercising a subset of the API class' features. For instance, our consuming class never attempts to access the "url" property of the API class. If it had, the value would be "undefined" because the InstrumentedAPI doesn't surface the underlying "url" member, it only surfaces the methods.

So, it is merely by coincidence that these two classes can be swapped in this demonstration. The InstrumentedAPI class may quack like the API class; but, it doesn't really talk or walk like the API class. If we switch this example over to TypeScript, this discrepancy is immediately caught by the compile-time type checker:

// Require the core node modules.
import chalk from "chalk";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

// I provide a set of methods of interacting with an API.
class API {

	private url: string;

	constructor( url: string ) {

		this.url = url;

	}

	// ---
	// PUBLIC METHODS.
	// ---

	public async get() : Promise<void> {

		console.log( chalk.cyan( "Making request to:", this.url ) );

		return( await Promise.resolve() );

	}

}

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

// I implement the API interface, but add timing instrumentation for the methods.
class InstrumentedAPI {

	private api: API;

	constructor( api: API ) {

		this.api = api;

	}

	// ---
	// PUBLIC METHODS.
	// ---

	public async get() : Promise<void> {

		var startedAt = Date.now();
		var promise = this.api.get();

		var logHandler = /*function*/() : void => {

			console.log( chalk.grey( `... get() took ${ Date.now() - startedAt }ms.` ) );

		};

		promise.then( logHandler, logHandler );

		return( promise );

	}

}

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

function main( api: API ) : void {

	api.get();

}

// Notice that the version of the API we're passing into the main function is actually
// the instrumented / wrapped version. This will allow us to see the timing of all the
// API functions.
main(
	new InstrumentedAPI(
		new API( "http://my-fancy-api.coolbeans" )
	)
);

As you can see, this is the same code with added type and access annotations. Notice, however, the main() function is expecting an object of type "API". And, when we go to run this example through ts-node, we get the following terminal output:

Argument of type 'InstrumentedAPI' is not assignable to parameter of type 'API'. Property 'url' is missing in type 'InstrumentedAPI'. (2345)

While this worked in native JavaScript, it immediately blows up in TypeScript because TypeScript understands that I can't substitue the InstrumentedAPI class for the API class. These two classes may have the same public methods; but, they do not have the same structure and are therefore not "duck typeable". In the JavaScript version, I was able to skate by on the assumptions that I was making about how the class instance would be consumed. In TypeScript, however, the type-checker prevents me from make those assumptions.

When I first encountered this problem, I tried to "fix it" by coupling the classes more closely. First, I tried to use "implements":

class InstrumentedAPI implements API { ... }

This doesn't work for the same reason that the duck typing doesn't work - the two classes have different structures.

Next, I tried to use "extends":

class InstrumentedAPI extends API { ... }

This doesn't work because "extends" requires "super()". And, in the InstrumentedAPI constructor, I don't have the necessary arguments to pass into the API constructor. And, of course, "extends" in this case doesn't even make any sense - the InstrumentedAPI isn't a "type of" API, it's a "wrapper" of the API.

Ultimately, after failing to get around the strict parenting of the TypeScript compiler, I realized that I had to do one of two things: either I had to change the type-annotation for the method that receives the InstrumentedAPI class:

function main( api: InstrumentedAPI ) : void {
	// ...
}

Or, I had to refactor both classes to implement a common interface:

interface API {
	get(): Promise<void>;
}

class APIClient implements API {
	// ....
}

class InstrumentedAPIClient implements API {
	// ...
}

function main( api: API ) : void {
	// ...
}

main(
	new InstrumentedAPIClient(
		new APIClient( "http://my-fancy-api.coolbeans" )
	)
);

Both of these approaches satisfy the TypeScript compiler. And, as far as I'm concerned, both of these approaches are actually cleaner than the "duck typing" approach that I just assumed would work in the native JavaScript example. Ultimately, these two approaches represent so much of what I love about TypeScript's type annotations: that you can't make assumptions willy-nilly - that you have to create clear, self-documenting, and unsurprising code.

ASIDE: When looking at the earlier error message, it may be unclear as to why a "private" member is part of the "structure" of a class. I can tell you that this definitely surprised me. And, at first, I thought this violated the very concept of encapsulation. It turns out, however, this restriction is by design. Apparently, in many class-based languges like TypeScript (and Java, C#, C++, Swift, PHP), access permissions are enforced at the Class level, not the Instance level. Which means, one instance of class can access the private variables of another instance of the same class. Therefore, part of walking and talking and acting like a "Duck" means that you have same private members that a "Duck" has.

Epilogue on Angular With TypeScript

In a context, like Angular, where the type annotations are used to power the dependency-injection mechanism, using an Interface may be limiting since Interfaces aren't "types". In such cases, I have found that using an Abstract Base Class, in lieu of an interface, satisfies both needs: separating-out the meaningful API portion for sub-classing and implementation while still allowing for dependency-injection.

Want to use code from this post? Check out the license.

Reader Comments

15,841 Comments

@Meziantou,

It's pretty great, right?! One thing that I've started doing more recently is explicitly adding "undefined" or "null" to the type-definitions. So, if a property on an object is nullable, I'll actually put that in the type:

public thing: Thing | null;

Right now, I don't have the strict-null flag turned-on. But, that's the direction I'm moving in. The more explicit the better! I'll take a look at your article to see what else I might be missing.

3 Comments

@Ben,

The strict-null flag is maybe the most useful flag. It helps you detect hundreds or thousands of potential errors in your code before the deployment. I started using it when TypeScript 2.0 was released, and it really helps me to write better code. Other languages, such as C#, are also introducing this compiler flag :)

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel