From JavaScript to TypeScript, Pt. IIB: Designing with Classes, Interfaces, & Mixins

Class-based design has become such an instinct that many developers can’t imagine any alternative.

Fortunately for that lot, ES6 adds a class keyword to simplify the syntactical cacophany of working with JavaScript’s native prototypes. TypeScript goes further, adding support for access modifiers; interfaces; enums; and a smörgasbård of other classical object-oriented goodies.

Of course, JavaScript doesn’t have native classes. Nor does it have native interfaces. Nor does it have native access modifiers. TypeScript does a good job of emulating them, but the illusion can’t be total. Keep that in mind, as it’s possible to slip past the compile-time checks if you’re clever enough.

After reading this article, you’ll be able to:

  • Write and design robust classes and interfaces;
  • Decide whether classes or interfaces are more appropriate for your use case; and
  • Article the major principles of object-oriented programming and design.

All the code samples for this article are hosted at my GitHub . Clone it down, and run:

git checkout Part-2B-OOP_Design_Principle

If you’re on Windows, you should be able to run tsc --target ES5 . You’ll get a frightening bundle of errors, but you can safely ignore them.

If you’re on Linux or Mac, you’ll still run tsc , but you can pipe the errors to nowhere:

# Compile all TS files, and silence irrelevant errors. tsc --target ES5 > /dev/null 2>&1

Either way, you should end up with a folder called built with the transpiled JavaScript.

Catch Up : Read From JavaScript to TypeScript Parts I and IIA

Classes: Background & Design Considerations

At the risk of sounding like a broken record: JavaScript does not have classes . It has a class keyword . These are very different things.

JavaScript uses prototype-based delegation to achieve what Java and C++ accomplish with classes. Even though we might be more comfortable with classical OOP, and even though we might be able to emulate it with prototypes, it will only ever be an emulation .

Under the hood, it’s prototypes all the way down.

That’s why it’s crucial to understand how JavaScript’s classes and prototypes work under the hood . You don’t have to like them, but life will be easier if you’re familiar with them.

Abstraction & Code Reuse

DRY code is not necessarily good code, but good code is pretty much always DRY.

Avoid duplicate code is such a common admonition in modern development that it’s hard to write the same code twice without feeling . . . Dirty. And for good reason: If you write your code in one place, and reuse it elsewhere, you’ll only have one place to worry about when you debug, maintain, or extend it.

The basic motivation for classes and objects derives from a similar principle

Objectsprovide a way to associate a data of a certain shape with behavior .

Objectsprovide a way to associate a data of certain shapes with behaviors — that is, things you’ll often want to do with that data. Classes provide a way to desribe those shapes and behaviors in the abstract, making it easy for us to create arbitrariy many similar objects throughout the lifetime of our program.

Put another way, a class is like a blueprint: It describes how to build a house. The house is the object itself. Building the house according to the blueprint is analogous to instantiating an object from the class. If we want to change the way the house is built, everywhere it’s built, all we have to do is change the blueprint — not the houses.

We create classes and objects that reflect the structural components of the problems we’re solving. This allows us to reason about our programs at a high level of abstraction , which is one of the fundamental aspects of OOP.

// user.ts "use strict";  // Blueprint :: This doesn't create users. It just describes them. class User {      constructor (private _name : string,                  private _email : string) {}      get name () : string  { return this._name; }      get email () : string { return this._email; }      speak () : void { console.log(`I am ${this.name}!`);  }  // Instantiation :: Create an actual thing that acts as the class describes. const peleke = new User('Peleke', 'resil.design@gmail.com'); console.log(peleke.name);

An object expresses its behavior through the functions attached to it, called methods , and it holds its data , or state , in instance variables .

In JavaScript, instantiated objects get their own instance variables, but delegate method calls to the class’s prototype object. In other words, each object gets its own data, but shares methods with other instances of the class.

That means two things:

  1. If you change methods on the class prototype, all of your instances will behave differently, even if you created them before making the change; and
  2. If you add methods to the class’s prototype, all of your instances will be able to call them, even if you created your objects before adding the new methods.
peleke.speak(); // 'I am Peleke!' peleke.hasOwnProperty('speak'); // false; delegated to User.prototype peleke.hasOwnProperty('_name'); // true; 'private' property is still technically visible  User.prototype.speak = null; try {     peleke.speak(); } catch (err) {     // Throws TypeError }

This dynamism is the reason prototypes are so powerful. It’s also one of the reasons that we can’t perfectly emulate classes.

Unfortunately, declaring methods private to prevent others from overriding it elsewhere doesn’t solve the problem — that keeps you from using it in the first place*.

You’ll also notice that, while you can’t read or write private variables, you can still "see" them at runtime. In most popular object-oriented languages, that would throw an access error.

In the classical model, methods and instance variables are mostly final after instantiation. In other words, under normal circumstances, you can’t assign new methods or properties to an object after it’s been created.

These are a couple fundamental assumptions of classical design patterns that don’t hold in our dynamic environment. Neither is likely to surprise longtime JavaScripters, but they’re minor gotchas for folks migrating from other OO languages.

* You can return a function from a private getter , though, which is a nifty potential solution to the overwrite problem.

Encapsulation & Implementation Hiding

Another advantage of classes is that that they allow us to keep an object’s internals hidden from the outside world. We want people to know how to use our objects; we don’t want them to know how they work.

Exposing as little information about your programs as possible is almost always a good idea. This is called the principle of least privilege , or the Law of Demeter .

This principle implies that our objects should be defined by two things:

  1. An Interface An object’s interface is what you promise it can do — in other words, a list of its public methods.
  2. Hidden Implementation Details . If you ask an object to sort a list of words, whether it use quicksort, mergesort, or O(n) black-magic-sort is irrelevant. All you care about is the sorted list it hands back. How it gets it to you is none of your business.

Separating what an object can do from how it does them is called encapsulaton , and it’s one of the cornerstones of good object-oriented design.

The reason is that clients who know a method’s implementation will write code that relies on that implementation. If the implementation changes, their code breaks, they complain, maintainers have to fix it, and everyone’s unhappy.

Now, it’s clear that library authors have to worry about this, because they literally have clients to support. But the issue is more general than that: Any part of your code that uses one of your objects is a client of that object. If all of your client code depends on the object’s implementation, changing your object means changing the code . . . Everywhere.

That largely defeats the purpose of collecting everything into an object in the first place. Ensuring we don’t fall into that trap is one of the reasons encapsulation is so essential to good design.

The interface of a class is the one place where we assert all of the assumptions that a client may make about any instances of the class; the implementation encapsulates details about which no client may make assumptions . ~ Grady Booch, Object Oriented Design & Analysis (pp. 52) , emphasis mine

To recap, encapsulation buys us:

1. Safety , by ensuring clients can’t muck with an object’s guts; and

  1. Flexibility , because clients can’t tell if we change internal details as long as our objects still satisfy their interface.

JavaScript has traditionally achieved encapsulation via clever manipulation of closures . TypeScript also provides the private access modifier for properties, which prevents reads or writes at compile-time. It doesn’t truly hide the property, however, so you can’t trust it to provide airtight encapsulation.’

Swapping Backends

Let’s imagine we’re prototyping an app that fetches definitions for all the words in a user-input sample of Spanish text.

We’re still figuring out the details, so we expect to be . . . Erm, promiscuous with our back-end, so to speak.

"use strict"; /* I've omitted some mock implementations from this code, so it won't compile.   *   The file, vocabulary_list.ts, on the repository, /does/ compile.   */  const config = { "use strict";  const CONFIG = {     api : 'wordreference',     key : 'API_KEY' };  class VocabularyList {    private english_words : Array<string>;      constructor (private spanish_words : Array<string>) {       this.english_words = [];     }      fetchWords () : Map<string, string> {         return this.parseResponse ( this.makeRequest(CONFIG.api, CONFIG.key) );     }      private makeRequest (api : string, key : string) : any {       const magicalJson = { /* For illustration */ }       return magicalJson;      }      private magicallyGetWord (word : string) : any {       // Mock data to please compiler       return { english_data : 'English language data' }     }      // This logic is specific to WR's (made-up) response format:     //    We have to extract the english_entries property from the response     private parseResponse (json : any) : Map<string, string> {         const translations : Map<string, string> = new Map<string, string>();          this.spanish_words.forEach((spanish_word) => {             // Get word from API with magic spell             const translation_data = this.magicallyGetWord(spanish_word);              // Get data from WORDREFERENCE response             const english_data = translation_data.english_data;              // Get the english_data property -- let's pretend it's also an object           translations.set(spanish_word, english_data)         });          return translations;     } }

Works great, until WordReference shuts down its API ( which it kind of did ).


After a long stint at the drawing board, we jump ship to Merriam-Webster .

const config = {     api : 'merriam-webster',     key : 'NEW_API_KEY' };  class VocabularyList {      // Nothing new . . .       private parseResponse : Array<string> (json : any) {         const translations : Map<string, string> = new Map<string, string>();          this.spanish_words.forEach((spanish_word) => {             // Get word from API with magic spell             const translation_data = this.magicallyGetWord(spanish_word);              // Get data from MERRIAM-WEBSTER response --              //   this is the only place we need to make a change!             const english_data = getDataFromUglyResponse( translation_data );           // Get the english_data property -- let's pretend it's also an object           translations.set(spanish_word, english_data)          });          // Create an information-packed object to send back to the caller         return buidObject(english_data);     } }

Unfortunately, this API doesn’t deliver everything we need in a nice packaged english_data property, so we have to put it together ourselves. That’s what getDataFromUglyResponse is for.

Other parts of our application — our clients — use the VocabularyLists ‘s fetchWords method, knowing that it’ll hand back a map of translated words.

What they don’t know is whether fetchWords is talking to WordReference, Merriam-Webster, or the Oxford English Dictionary. All they need to know is its interface — that it can fetchWords . They don’t know how . That’s an implementation detail.

If they did know that VocabularyList got its words from WordReference, and had to provide their own logic to parse its response, we’d have at least two problems.

  1. The design would suck, because we’d have to parse JSON in a function that we really should just pass everything it needs; and
  2. Refactoring would suck, because you’d have to write new parsing logic all over the place .

That would be called programming to an implementation , and it’s one of the cardinal sins of object-oriented design. It’s not just a theoretical no-no, either: This mistake has been responsible for a substantial proportion of my debugging time, as well as that of others. Avoid it like the plague it is.

What we did instead is program to an interface . We wrote our code to expect fetchWords to send us an object with all of the information we need, so we can pick and choose what we want from it.

How VocabularyList gets it to us is irrelevant. Its interface makes a promise; we trust the promise; and so VocabularyList is obligated to keep it.

If the details change, we only worry about it in the code for the class — nowhere else. This kind of flexibility, and stability of client code in the face of implementation changes, is precisely the point of encapsulation and information hiding.

Class Relationships

Classes define the shape of the objects they instantiate, as well as their API.

Classes can also be related to other classes. The most common relationships are:

  • Subclass relationships, and
  • Multiple inheritance.

JavaScript doesn’t support multiple inheritance, but TypeScript’s interfaces and mixins scratch a similar itch.

Subclassing & Polymorphism

Subclassingis the process of creating a new class that inherits the behavior and data members of another class. A class you create through subclassing is called a child class , or a derived class .

// raw_fish.ts class Fish {      constructor (public name : string) { }      // Pseudo-abstract method :: No-op, unless     //   a subclass provides an implementation.     cook () : void { }  }  class SushiFish extends Fish {      constructor ( name : string, cooked : boolean = false ) {         super(name);     }    cook () : void {        console.log('You don/'t cook a sushi fish!')   }  }  class CookedFish extends Fish {    constructor (name : string, cooked : boolean = true) {       super (name);   }  }  const tuna = new Fish('Tuna'); const dinner = new CookedFish('Halibut');  tuna.cook(); // 'Fish has been cooked!' dinner.cook(); // 'This fish is already cooked!'

To create a subclass, you use the extends keyword, followed by the base class name. CookedFish is our derived class; Fish is called its superclass .

Note that we can call cook on our dinner object, even though we didn’t define that method explicitly. That’s because every method on Fish is available via inheritance . Since a CookedFish is a type of Fish , it should expose the same API.

It is not, however, obligated to behave identically. Both our SushiFish and our CookedFish classes override their parent’s cook method, and so behave differently.

The fact that different classes with unique behavior can share the same interface by relation to a common superclass is called polymorphism .

Multiple Inheritance

Languages with multiple inheritance allow you to inherit from several parents classes at once.

JavaScript doesn’t support this, but if it did, it might look like this:

class Wolf extends Canine, Predator {     constructor () {     // Which superconstructor do we use? Hm . . .      super();     } }  const wolfie = new Wolf();  wolf.howl(); // Canines howl wofl.hunt(); // Predators hunt

TypeScript doesn’t provide sugar for this. But we can achieve similar behavior using either interfaces or mixins.

Interfaces & Their Abstractions

An interface describes what you can expect an object to do, but it doesn’t define how the object should do it.

More technically, an interface defines an API that its implementers must expose, but defers the implementation to the implementing classes.

While classes can only inherit from a single superclass, they can implement multiple interfaces. This allows a lot of flexibility in organizing and defining the behavior of our objects.

// printable.ts  interface Identifiable {      name : string;      identify ();  }   interface Printable {      text : Array<string>;      print ();  }   class Book implements Identifiable, Printable {     // Creative implementations here . . .   }

Major benefits of interfaces are:

  • Abstraction . They encourage you to design systems in terms of common behaviors, and discouage you from writing your code with excessive attention to implementation details.
  • Flexibility . Interfaces define behavior, but defer the implementation(s) of that behavior to the objects that implement the interface.
  • Composability . You can implement an arbitrary number of interfaces, allowing you to compose an arbitrary number of APIs into a single object.

One downside is that interfaces can’t provide method definitions. This means you’ll have to provide implementations in each class that implements the interface.

On one hand, that’s largely the point. Deferring implementation details to implementing classes is what makes interfaces so powerful.

But sometimes, a number of otherwise unrelated classes will implement the same interfaces in exactly the same way.

// printable.ts "use strict";  class Book implements Printable, Identifiable {      // Shorthand; creats private members automatically     constructor ( public name : string,  public text : Array<string>) { }      print () : void {         console.log(this.text);     }      // This is the same in both classes!     identify () : void {         console.log(this.name);     } }  class User implements Identifiable {      constructor (public name : string) { }      // This is the same in both classes!     identify () : void {         console.log(this.name);     } }

Inheritance is the wrong abstraction for creating largely unrelated classes that act similarly, so extends doesn’t make sense. But implements forces us to:

  1. Write the same implementation in multiple places; or
  2. Define the implementation somewhere else, and have both objects refer to it.

Crappy design choice, don’t you think?

  1. Neither solution is DRY. If the common behavior changes, you’ll have to change it in every implementing class.
  2. Defining a single method somewhere else is a better design choice. But externalizing the implementation somewhere arbitrary largely defeats the organizational purposes of classes and interfaces.

Languages like Groovy and Scala have traits , which are effectively interfaces that hold state and implement methods.

TypeScript offers two ways to go about trait-based design:

  1. Abstract classes; or
  2. Mixins.

An abstract class lies somewhere between a normal class and an interface.

Similar to interfaces, you:

  1. . . . Can’t instantiate an abstract class. Rather, you must extend from it, and instantiate the derivcd class.
  2. . . . Can include method signatures without definitions, which derived classes must implement. The only difference to interfaces is that you mark these with the keyword abstract in an abstract class.

And similar to classes, you:

  1. . . . Must use extends to inherit from an abstract class. This means you can only have one abstract parent.
  2. . . . Can provide method implementations, which your derived classes will inherit.

The major difference between abstract classes and traits is that you can extend a single abstract class, whereas you can implement an arbitrary number of traits.

Abstract classes are a good design choice if you’re abtracting over several different child structures, which will differ in their implementation of an interface while sharing certain common behaviors.

If you ‘ve got several mostly unrelated objects implementing an interface identically, though, than a mixin is probably the better solution.

Mixins vs Implements

Thre are few hard and fast rules where design is concerned, but you’ll find certain patterns pop up as you do it more. If you’re describing behavior:

  • . . . So general that every class implementing it will do so differently, use an interface .
  • . . . General enough that unrelated classes will implement it, but common enough that they’ll mostly do so in the same way, use a mixin .

If the answer to the former is yes , consider an interface. If the answer to the latter is yes , consider mixins. Don’t be afraid to ignore the guideline, though; code to a problem, not to a guideline.

A Parting Comment

"If you could do Java over again, what would you change?" – "I’d leave out classes." ~ James Gosling, creator of Java, as quoted by Alan Holub, Holub on Patterns

Never thought I’d hear that one.

Turns out that Gosling’s point is not that classes are intrinsically bad. Rather, it’s that using extends where one should use implements has caused more headaches than anticipated, and that you should be careful creating unnatural class hierarchies.

Implementation Inheritance

Using extends to build new classes is called implementation inheritance , because subclasses created with extends have the same methods as their parents, with the same implementations.

This sort of inheritance is natural if you’re creating a class that’s a special case of another — say, a FreshwaterFish that extends our Fish class.

// freshwater_fish.ts "use strict";  class Fish {      constructor (private name : string) { }  }  class FreshwaterFish extends Fish {      constructor (name : string, private salt_tolerant : boolean = false) {         super(name);     }  }

FreshwaterFish class behaves exactly like our Fish class, but holds additional state.

More generally, subclassing makes sense when you can use your subclass anywhere you can use your superclass. A program that receives a FreshwaterFish but expects a Fish should remain well-typed throughout, even if it runs differently.

This principle is encoded in the Liskov Substitution Principle . Covering it properly is out of the scope of this article, but take the time to read up on it at some point.

Interface Inheritance

The alternative to implementation inheritance is interface inheritance , or using the implements keyword to declare that an object exposes the API defined by a given interface.

Interface inheritance has a number of advantages. It:

  • Loosens coupling between parts of your program;
  • Eliminates the problem of changes to a superclass breaking subclasses;
  • Affords greater flexibility, because any class implementing an interface can implement its API differently.

A class that implements an interface is still said to be a subclass of the interface; and by corollary, an interface can be a supertype.

This, or That?

A natural reaction at this point is extreme tenderness at being told to use extends with caution.

There are those who would argue that extends is evil . There’s a case for that, but I don’t think categorical avoidance is a very productive solution.

Subclassing is not inherently worse than any other language feature. Gosling himself admits to using extends more than implements , in spite of the above quip.

Using subclasses wrong can suck, though, and it’s true that a lot of people have a habit of using subclasses where interfaces, mixins, or a different pattern entirely would make more sense. That makes for unmaintanable code, mysterious subclass behavior, and an abundance of senseless class hierarchies.

The solution isn’t to avoid extends : It’s to design your code well. Subclasses are easy to understand and easy to implement, which is why they’re easy to overuse. That can come back to bite you if you get overzealous.

Just remember to always consider your alternatives. As long as you do that, your hierarchies should turn out alright.


By now, you should be able to:

  • Define the Four Fundamentals of OOP : Abstraction, Encapsulation, Polymorphism, and Subclassing;
  • Decide whether an interface, class, or mixin is the best abstraction for your problem; and
  • Weigh the pros and cons of building systems in terms of implementation or interface inheritance.

Try reading some code that uses these principles to understand how they work in the real world — I’ve been digging through the Angular 2 source , myself.

As usual, feel free to leave questions in the comments, or shoot them to me on Twitter ( @PelekeS ); I’ll get back to everyone individually.

转载本站任何文章请注明：转载至神刀安全网，谢谢神刀安全网 » From JavaScript to TypeScript, Pt. IIB: Designing with Classes, Interfaces, & Mixins

分享到：更多 ()

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址