Classes
TypeScript has good support for Object Oriented Programming using classes. Classes are user defined types that combine data (variables/properties) and behavior (methods) which help us build our programs with resuable, extensible and composable building blocks.
Let us see how we can use TypeScript classes to make better designed programs.
Shown below is a simple example of a TypeScript class. In this example the class represents a Person and it has three attributes(firstName, lastName and id) and one method (greeting). We also have one class variable fullName
.
class Person {
fullName:string;
constructor(public id:number, public firstName, public lastName){
this.fullName= this.firstName + " " + this.lastName;
}
greeting():string{
return `Hello from an instance of Person, my name is ${this.fullName}`;
}
}
Constructor: The constructor function is invoked when we create an instance of the class using the new
keyword. We use the contructor to ensure that the class is constructed in a valid state. In TypeScript you can only have one constructor per class. TypeScript does not support constructor overload. You can use optional parameters to get some of the overloading behavior if necessary.
parameter properties: In this example we are creating public properties using parameter properties by using the public keyword in the contructor parameter list. This sets up the instance variables initializes and exposes them appropriately. (How cool is that!) You can also use the private and protected keywords (which we will see later) with parameter properties.
instance variables: We can also create instance variables explicitly as is done in the case of the fullName variable. This variable is initialized by the contructor and it is public by default.
methods: There is one method in this class (greeting()) that takes no parameters are returns a string.
As you have just seen classes have members which are:
- constructors
- methods
- properties
- variables
Class variables and accessibility modifiers
Let us briefly look at the visibility of the variables. In TypeScript all variables are essentially public. Encapsulation is one of the pillars of Object oriented programming (OOP). To many programmers, encapsulation has come to mean the ability to have private variables. This also requires the programmer to write lines of code (with no value) to provide getters and setters!
The real essence of Encapsulation is the "ability to encapsulate change". This is something that we should strive for when we design our program, but achieving encapsulation needs us to go far beyond making variables private. We need to better understand why consumers of our classes need to access all the properties of the object rather than invoking the behavior that they expose. We will cover some of this in the sections on Design.
public
, private
and protected
modifiers
As we said before variables in TypeScript are public by default. If we want to change this behavior we can use the private
and protected
keywords.
private variables are accessible only from the class itself.
protected variable are accessible on;y from the class and derived classes.
note There are some subtleties about how classes are compared for equality when they have protected members.
TypeScript uses a structural type system. When two different types are tested for equality if the types of all members are compatible, then we say the types themselves are compatible. On the other hand, for private
and protected
types which are being compared they must not only be structurally equal but their definitions must originate in the same declaration.
Properties and accessors: getters and setters
Now if we started off by defining a class that exposed a variable giving users access to the variable. If later you wanted to change this behavior, for example you wanted to do some validation when the variable was accessed then we can do this using properties.
class Person {
private _fullName:string;
constructor(public id:number, public firstName, public lastName){
this._fullName= this.firstName + " " + this.lastName;
}
get fullName(){
return this._fullName;
}
set fullName(fullName){
if(fullName !=undefined ){
this._fullName =fullName;
}
}
greeting():string{
return `Hello from an instance of Person, my name is ${this.fullName}`;
}
}
var p1 = new Person(1, "Jim", "Brown");
display(p1.greeting());
//accessing the property
display(p1.fullName);
//updating the propterty
p1.fullName ="John Smith"
display(p1.greeting());
Notice how the client of our class is not affected when we switched from direct access to the variable to the property.
The convention is to use _variableName
for the variable and variableName
for the property. (You can use any name for the private variable, but the convention is to use the underscore.)
gettors are denoted by the get
keyword and they are not allowed to have any parameters and can have an optional return type.
settors are denoted by the set
keyword and they can only have one parameter which is the type of the prperty being set and no return type.
To verify that the gettors and settor are working as designed try setting the fullName
to undefined
.
note: you need to set the compiler options to target ECMAScript 5 or later to use properties.
"compilerOptions": {
"target":"es5",
"module":"commonjs",
...
}
Static properties.
So far we were looking at the classes where the variables were associated with each object (instance of the class). But there are some situations where we will need to have variables that are attached to the class and are common to all instances. This can be done using static
properties.
note Even though this is a well supported feature, this is something that should be used carefully. Since we are sharing state across all instances of a class and we can make subtle and hard to detect errors.
Suppose we wanted to detect how many instances of the Person class exist at any given time. In general this is a tricky thing to do. We can do this easily using
static variables. In this example we add the
instanceCounter
variable to the class and increment it every time we create a new instance using the constructor.
class Person {
fullName:string;
static instanceCounter = 0;
constructor(public id:number, public firstName, public lastName){
this.fullName= this.firstName + " " + this.lastName;
Person.instanceCounter +=1;
}
greeting():string{
return `Hello from an instance of Person, my name is ${this.fullName}`;
}
}
//display the number of instances
display(Person.instanceCounter);
note: you can only access static properties using the class not through instances of the class.
Inheritance
TypeScript uses standard patterns when providing OO features. Inheritance is one of the standard ways of extending classes to introduce new behavior.
Here is a simple example where we extend the Person to class. We create a derived class Employee that inherits from the Person class defined earlier.
Apart from all the inherited properites, the Employee class has two extra public properties
title
and company
. It also overrides the greeting
and toString
methods to provide its own implementation.
class Employee extends Person {
fullName: string;
constructor(public id: number, public firstName: string,
public lastName: string, public title: string, public company) {
//notice the call to super - this is required.
super(id, firstName, lastName);
}
toString(): string {
return `id:${this.id} Name : ${this.fullName} works at ${this.company} and has
Title : ${this.title}`;
}
greeting() {
return `Hello from an instance of the derived class Employee,
I am a ${this.title} and my name is ${this.fullName} . <br>` +super.greeting();
};
}
Note: extends is the keyword that we use in TypeScript to denote inheritance.
Using super : Notice that we are using the super
keyword to access different aspects of the base class from the derived class.
- constructor functions must call the the constructor in the base class using
super
to call the base class constructor. - Also we can access the base class methods and variables using
super
from the derived classes.
Polymorphism
This is a very central idea to OO programming.
we delegate to classes and sub-classes in a hierarchy to provide the required/correct behavior for their instances. So the user of the class just invokes the required method and we get different behavior based on the type.
The classic example of this is the toString
method on all types.
In TypeScript you do not need to use the override
keyword explicitly to override behavior defined in the base class.
var p = new Person(1, "Jim", "Brown");;
let f = new Employee(2, "I . M.", "Dumb", "CEO", "Acme Inc");
let e = new Employee(3, "R. U.", "Done", "project Manager", "Acme Inc");
display("An example of polymorphism");
let people = [p, e, f]
people.map((x) => { display(x.greeting()); });
In this example, the appropriate greeting
method defined in the base/derived class is called.
Encapsulating and delegating behavior to members of a well designed class heirarchy goes a long way in creating elegant programs that are easy to understand and maintain.
Abstract Base classes:
These are classes that provide some implementation, but also are incomplete in that some of the implementation has to provided by the derived classes. This means that abstract base classes (A.B.C) cannot be instantiated. Since ABCs are only partial implementations, it does not make sense to create instances of them. Other than this important detail, ABCs are similar to regular base classes.
We should use Interfaces (covered in the next section) in most cases to specify the behavior of clasess, however A.B.Cs can be used to share implementation with derived classes.
class construction details
Let us look at what happens when we create a class in TypeScript and it is converted to JavaScript.
class Person {
private _fullName: string;
static instanceCounter:number=0;
constructor(public id: number, public firstName, public lastName) {
this._fullName = this.firstName + " " + this.lastName;
}
get fullName() {
return this._fullName;
}
set fullName(fullName) {
if (fullName != undefined) {
this._fullName = fullName;
}
else {
console.log("you cannot set the fullName to `undefined`");
}
}
greeting(): string {
return `Hello from an instance of Person, my name is ${this.fullName}`;
}
}
Here is the JavaScript generated by the TypeScript compiler
var Person = (function () {
function Person(id, firstName, lastName) {
this.id = id;
this.firstName = firstName;
this.lastName = lastName;
this._fullName = this.firstName + " " + this.lastName;
}
Object.defineProperty(Person.prototype, "fullName", {
get: function () {
return this._fullName;
},
set: function (fullName) {
if (fullName != undefined) {
this._fullName = fullName;
}
else {
console.log("you cannot set the fullName to `undefined`");
}
},
enumerable: true,
configurable: true
});
Person.prototype.greeting = function () {
return "Hello from an instance of Person, my name is " + this.fullName;
};
Person.instanceCounter = 0;
return Person;
}());
Some key points:
constructor There is a special function created in Javascript that will represent the constuctor and this will take the name of the class being constructed. If we make an assignment like
let p = Person;
let p: typeof Person; // this is equivalent
The the variable p
will be assigned the constructor function.
So you can now construct the class as follows:
let p = new p(1, "Jim", "Brown");
//of course you do this in one step always
//let p = new Person(1, "Jim", "Brown");
Also, p
is not yet contstructed until you call new
and invoke the constructor. But you will have access to all the class variables (static variables)
via p before it is constructed.
The prototype: All the class construction depends on the prototype
which is where all the methods and properties are stored. Notice that you can do all this in JavaScript, notice how much cleaner the typeScript syntax is!
Classes as types/Interfaces
This is an obscure point and you will not use this but I include it for completeness.
Since classes define types, they also define constraints on variables, in this sense they act like interfaces.
We can define variables to be of the the type of the class, then they can only have the properties defined by their type.
class Person{
fullName:string;
}
let p:Person;
p.fullName="Jim Brown"
p.age=50;// This will error in TypeScript
Interfaces are more general, since you can also define methods that do not contain any implementation. For classes if you define methods you have to provide implementation.
We will look at interfaces in the next section.