官术网_书友最值得收藏!

Classes, interfaces, and inheritance

Now that we have overviewed the most relevant bits and pieces of TypeScript, it's time to see how everything falls into place when building TypeScript classes. These classes are the building blocks of Angular applications.

Although class was a reserved word in JavaScript, the language itself never had an actual implementation for traditional POO-oriented classes as other languages such as Java or C# did. JavaScript developers used to mimic this kind of functionality by leveraging the function object as a constructor type and instantiating it with the new operator. Other standard practices, such as extending function objects, were implemented by applying prototypal inheritance or by using composition.

Now, we have an actual class functionality, which is flexible and powerful enough to implement the functionality our applications require. We already had the chance to tap into classes in the previous chapter. We'll look at them in more detail now.

Anatomy of a class

Property members in a class come first, and then a constructor and several methods and property accessors follow. None of them contain the reserved function word, and all the members and methods are annotated with a type, except constructor. The following code snippet illustrates what a class could look like:

class Car {

    private distanceRun: number = 0;

    private color: string;

    

    constructor(private isHybrid: boolean, color: string =     'red') {

        this.color = color;

    }

    

    getGasConsumption(): string {

        return this.isHybrid ? 'Very low' : 'Too high!';

    }

    

    drive(distance: number): void {

        this.distanceRun += distance;

    }

    

    static honk(): string {

        return 'HOOONK!';

    }

    get distance(): number {

        return this.distanceRun;

    }

}

The class statement wraps several elements that we can break down:

  • Members: Any instance of the Car class will contain three properties: color typed as a string, distanceRun typed as a number, and isHybrid as a boolean. Class members will only be accessible from within the class itself. If we instantiate this class, distanceRun, or any other member or method marked as private, it won't be publicly exposed as part of the object API.
  • Constructor: The constructor executes right away when we create an instance of the class. Usually, we want to initialize the class members here, with the data provided in the constructor signature. We can also leverage the constructor signature itself to declare class members, as we did with the isHybrid property. To do so, we need to prefix the constructor parameter with an access modifier such as private or public. As we saw when analyzing functions in the previous sections, we can define rest, optional, or default parameters, as depicted in the previous example with the color argument, which falls back to red when it is not explicitly defined.
  • Methods: A method is a special kind of member that represents a function and, therefore, may return a typed value. It is a function that becomes part of the object API but can be private as well. In this case, they are used as helper functions within the internal scope of the class to achieve the functionalities required by other class members.
  • Static members: Members marked as static are associated with the class and not with the object instances of that class. We can consume static members directly, without having to instantiate an object first. Static members are not accessible from the object instances, which means they cannot access other class members using the this keyword. These members are usually included in the class definition as helper or factory methods to provide a generic functionality not related to any specific object instance.
  • Property accessors: To create property accessors (usually pointing to internal private fields, as in the example provided), we need to prefix a typed method with the name of the property we want to expose using the set (to make it writable) and get (to make it readable) keywords.

Constructor parameters with accessors

Typically, when creating a class, you need to give it a name, define a constructor, and create one or more backing fields, like so:

class Car {

    make: string;

    model: string;

    

    constructor(make: string, model: string) {

        this.make = make;

        this.model = model;

    }

}

For every field you want to add to the class, you usually need to do the following:

  • Add an entry to the constructor
  • Add an assignment in the constructor
  • Declare the field

This is boring and not very productive. TypeScript eliminates this boilerplate by using accessors on the constructor parameters. You can now type the following:

class Car {

    constructor(public make: string, public model: string) {}

}

TypeScript will create the respective public fields and make the assignment automatically. As you can see, more than half of the code disappears; this is a selling point for TypeScript as it saves you from typing quite a lot of tedious code.

Interfaces

As applications scale and more classes and constructs are created, we need to find ways to ensure consistency and rules compliance in our code. One of the best ways to address the consistency and validation of types is to create interfaces. In a nutshell, an interface is a blueprint of the code that defines a particular field's schema. Any artifacts (classes, function signatures, and so on) that implement these interfaces should comply with this schema. This becomes useful when we want to enforce strict typing on classes generated by factories, or when we define function signatures to ensure that a particular typed property is found in the payload.

Let's get down to business! In the following code, we're defining the Vehicle interface. It is not a class, but a contractual schema that any class that implements it must comply with:

interface Vehicle {

    make: string;

}

Any class implementing this interface must contain a member named make, which must be typed as a string:

class Car implements Vehicle {

    make: string;

}

Interfaces are, therefore, beneficial to defining the minimum set of members any artifact must fulfill, becoming an invaluable method for ensuring consistency throughout our code base.

It is important to note that interfaces are not used just to define minimum class schemas, but any type out there. This way, we can harness the power of interfaces by enforcing the existence of specific fields, as well as methods in classes and properties in objects, that are used later on as function parameters, function types, types contained in specific arrays, and even variables.

An interface may contain optional members as well. The following is an example of defining an Exception interface that contains a required message and optional id property members:

interface Exception {

    message: string;

    id?: number;

}

In the following code, we're defining the blueprint for our future class, with a typed array and a method with its returning type defined as well:

interface ErrorHandler {

    exceptions: Exception[];

    logException(message: string, id?: number): void

}

We can also define interfaces for standalone object types. This is quite useful when we need to define templated constructor or method signatures:

interface ExceptionHandlerSettings {

    logAllExceptions: boolean;

}

Let's bring them all together:

class CustomErrorHandler implements ErrorHandler {

    exceptions: Exception[] = [];

    logAllExceptions: boolean;

    

    constructor(settings: ExceptionHandlerSettings) {

        this.logAllExceptions = settings.logAllExceptions;

    }

    

    logException(message: string, id?: number): void {

        this.exceptions.push({message, id });

    }

}

We define a custom error handler class that manages an internal array of exceptions and exposes a logException method to log new exceptions by saving them into the array. These two elements are defined in the ErrorHandler interface and are mandatory.

So far, we have seen interfaces as they are used in other high-level languages, but interfaces in TypeScript are on steroids; let's exemplify that. In the following code, we're declaring an interface, but we're also creating an instance from an interface:

interface A {

    a

}

const instance = <A> { a: 3 };

instance.a = 5;

This is interesting because there are no classes involved. This means you can create a mocking library very easily. Let's explain a what we mean when talking about a mock library. When you are developing code, you might think in terms of interfaces before you even start thinking in terms of concrete classes. This is because you know what methods need to exist, but you might not have decided exactly how the methods should carry out a task.

Imagine that you are building an order module. You have logic in your order module and you know that, at some point, you will need to talk to a database service. You come up with a contract for the database service, an interface, and you defer the implementation of this interface until later. At this point, a mocking library can help you create a mock instance from the interface. Your code, at this point, might look something like this:

interface DatabaseService {

    save(order: Order): void

}

class Order {}

class OrderProcessor {

    

    constructor(private databaseService: DatabaseService) {}

    

    process(order) {

        this.databaseService.save(order);

    }

}

let orderProcessor = new OrderProcessor(mockLibrary.mock<DatabaseService>());

orderProcessor.process(new Order());

So, mocking at this point gives us the ability to defer implementation of DatabaseService until we are done writing OrderProcessor. It also makes the testing experience a lot better. Where in other languages we need to bring in a mock library as a dependency, in TypeScript, we can utilize a built-in construct by typing the following:

const databaseServiceInstance = <DatabaseService>{};

This creates an instance of DatabaseService. However, be aware that you are responsible for adding a process method to your instance because it starts as an empty object. This will not raise any problems with the compiler; it is a powerful feature, but it is up to us to verify that what we create is correct. Let's emphasize how significant this TypeScript feature is by looking at some more cases, where it pays off to be able to mock away things.

Let's reiterate that the reason for mocking anything in your code is to make it easier to test. Let's assume your code looks something like this:

class Stuff {

    srv:AuthService = new AuthService();

    

    execute() {

        if (srv.isAuthenticated()) {}

        else {}

    }

}

A better way to test this is to make sure that the Stuff class relies on abstractions, which means that AuthService should be created elsewhere and that we use an interface of AuthService rather than the concrete implementation. So, we would modify our code so that it looks like this:

interface AuthService {

    isAuthenticated(): boolean;

}

class Stuff {

    constructor(private srv:AuthService) {}

    execute() {

        if (this.srv.isAuthenticated()) {}

        else {}

    }

}

To test this class, we would typically need to create a concrete implementation of

AuthService and use that as a parameter in the Stuff instance, like this:

class MockAuthService implements AuthService {

    isAuthenticated() { return true; }

}

const srv = new MockAuthService();

const stuff = new Stuff(srv);

It would, however, become quite tedious to write a mock version of every dependency that you wanted to mock away. Therefore, mocking frameworks exist in most languages. The idea is to give the mocking framework an interface from which it would create a concrete object. You would never have to create a mock class, as we did previously, but that would be something that would be up to the mocking framework to do internally.

Class inheritance

Just like a class can be defined by an interface, it can also extend the members and functionality of other classes as if they were its own. We can make a class inherit from another by appending the extends keyword to the class name, including the name of the class we want to inherit its members from:

class Sedan extends Car {

    model: string;

    

    constructor(make: string, model: string) {

        super(make);

        this.model = model;

    }

}

Here, we're extending from a parent Car class, which already exposes a make member. We can populate the members already defined by the parent class and even execute their constructor by executing the super method, which points to the parent constructor. We can also override methods from the parent class by appending a method with the same name. Nevertheless, we are still able to execute the original parent's class methods as it is still accessible from the super object.

主站蜘蛛池模板: 桂阳县| 繁昌县| 江城| 和顺县| 德昌县| 襄垣县| 绥德县| 大安市| 泾源县| 定州市| 永泰县| 剑川县| 达拉特旗| 静宁县| 孙吴县| 宁德市| 临沂市| 池州市| 霍林郭勒市| 洪雅县| 林西县| 林西县| 洪洞县| 临猗县| 沧源| 昌江| 治多县| 康定县| 腾冲县| 漯河市| 本溪| 金沙县| 康定县| 崇左市| 舒城县| 门源| 百色市| 定南县| 南安市| 格尔木市| 老河口市|