TypeScript Learning Adventures: A Tale of Love and Hate - Classes
Depending on your familiarity with modern JavaScript or other programming languages, you may already be acquainted with the purpose of classes and the principles of object-oriented programming.
To grasp the concept of classes, it's essential to grasp the concept of object-oriented programming.
Depending on your familiarity with modern JavaScript or other programming languages, you may already be acquainted with the purpose of classes and the principles of object-oriented programming.
The fundamental idea behind object-oriented programming and classes is to model real-world entities in your code, which will become clearer as you delve deeper into the topic.
The aim is to work with objects in your code that closely resemble real-life objects, making it more convenient for developers to read and use their code.
For instance, in the previous article, we talked about a project involving the delivery of packages.
So we could potentially create a class that manages individual packages. This class could store and display package details, enable users to add them to a shopping cart and perform other relevant functions.
Class vs Object
We can logically divide code into manageable pieces that make sense to humans. In modern programming languages like TypeScript and JavaScript, this is facilitated by objects, which are complex data structures with properties and methods.
Classes, which are supported in these languages, provide a way to create objects with predefined properties and methods.
The concept of classes revolves around the idea of managing different parts of our application logic with separate objects.
In JavaScript, objects are the tangible entities that we work with in our code. They are used to store data and execute methods on that data, serving as the building blocks of our application's functionality.
Classes are basically blueprints for objects.
Classes provide a way to define the structure of objects, specifying the data they should hold and the methods they should have. This allows us to easily create multiple objects with the same structure and methods based on the same class, which are referred to as instances of that class.
They provide an alternative to using object literal notation, making it more convenient and efficient to create and manage objects in our code.
How to create classes and objects?
We do this by using the class
keyword, just like that, and then the name of the class and there let's say we want a class that handles our package deliveries.
class Package {
title: string;
weight: number;
content: string[] = [];
constructor(title: string, weight: number) {
this.title = title;
this.weight = weight;
}
getDetails(): string {
return `Title: ${this.title}, Weight: ${this.weight} kg`;
}
}
const package = new Package('My package', 2);
console.log(package.getDetails()); // output: "Title: My package, Weight: 2 kg"
The convention is, to begin with, an uppercase character to indicate that this is a class.
If you are familiar with JavaScript development, you may already be familiar with the concept of constructor
functions, as classes are essentially syntactic sugar for them.
Although it may resemble an object, it is not actually an object. In objects, you typically use key-value pairs with a colon to specify a key name and its corresponding value.
Here that's not the case:
private title: string;
In a class, this is referred to as a "field". A class is defined using curly braces, but unlike key-value pairs, it simply declares the name of a key that will be present in objects created based on the class, along with the expected value type for that key.
Functions within classes are known as "methods", and the constructor method is a special method. "Constructor" is a reserved keyword recognized by TypeScript and modern JavaScript.
It is essentially a function
that is associated with the class
and is executed automatically when an object is created based on that class. This allows you to perform initialization tasks for the object being constructed.
So how to create an object
from class?
const package = new Package('Books', 2);
console.log(package.getDetails()); // output: "Title: Books, Weight: 2 kg"
To create an object from the class you defined, you can use the "new" keyword in TypeScript or JavaScript, followed by the name of the class, and then add parentheses.
This will invoke the constructor
, which can accept arguments.
In this case, the constructor takes two arguments, a string, and a number. This process will create a new JavaScript object based on the blueprint defined by the class.
Compiling to JavaScript
If we compile the class above to the Javascript with the following tsconfig.json
file:
{
"compilerOptions": {
"target": "ES6",
"module": "commonjs",
"sourceMap": true
}
}
we will get something like this:
var Package = /** @class */ (function () {
function Package(title, weight) {
this.content = [];
this.title = title;
this.weight = weight;
}
Package.prototype.getDetails = function () {
return "Title: ".concat(this.title, ", Weight: ").concat(this.weight, " kg");
};
return Package;
}());
var package = new Package('My package', 2);
console.log(package.getDetails()); // output: "Title: My package, Weight: 2 kg"
In essence, what we have here is a "constructor function", which has been a part of JavaScript for a long time and is a way to create object blueprints in vanilla, non-modern JavaScript.
It's a function that is invoked using the "new" keyword, and despite not having a return
statement, it returns an object, as seen when we call it.
This concept is not new and was not introduced by modern JavaScript or TypeScript. The idea of object blueprints has existed in JavaScript for a long time, but in the past, constructor functions were used, which could be less intuitive for developers familiar with other programming languages.
Modern JavaScript introduced the concept of classes with a cleaner syntax, and TypeScript supports this as well.
One of the advantages of TypeScript is its powerful compilation capability, allowing you to choose whether to compile to an older style that works in more browsers or to the more modern ES6 style that we saw earlier.
Private and public access modifiers
Let's take a look at this class:
class Package {
title: string;
weight: number;
content: string[] = [];
constructor(title: string, weight: number) {
this.title = title;
this.weight = weight;
}
getDetails(): string {
return `Title: ${this.title}, Weight: ${this.weight} kg`;
}
addContent(newContent: string){
this.content.push(newContent);
}
}
One problem we have with this class above is that we could change the content property from outside, not only with add method but by directly accessing the property. This can happen also with title and weight property as well.
We could do something like this:
const package = new Package('Books', 2);
package.content = ["Harry Potter and the Philosopher's Stone"];
package.content = ["The Lord of the Rings"];
and this is not good as you can overwrite everything in the content array.
Now, to fix this we can add a private
modifier to this property:
class Package {
public title: string;
public weight: number;
private content: string[] = [];
constructor(title: string, weight: number) {
this.title = title;
this.weight = weight;
}
getDetails(): string {
return `Title: ${this.title}, Weight: ${this.weight} kg`;
}
addContent(newContent: string){
this.content.push(newContent);
}
}
To restrict access to the content
property from outside of the class, you can use TypeScript's private
keyword to make it a private field.
By adding the private
keyword in front of the property declaration you are specifying that the content
property can only be accessed from within the class
itself.
This means that any method within the Package
class can still work with the content
property, but it cannot be accessed directly from outside of the class anymore. Also, it provides encapsulation and helps to ensure that the internal state of the class is not unintentionally modified from external code.
If you try to do so, you will get a compilation error:
property 'content' is private and only accessible within the class 'package'.
In TypeScript, you can mark methods as private
as well, in addition to properties. By using the private
keyword before a method declaration, you can specify that the method can only be accessed from within the class itself.
The default visibility for methods and properties in TypeScript is public
, which means they are accessible from outside of the class.
It's worth noting that in JavaScript, prior to modern versions, there was no concept of private
or public
properties. All properties were considered public
.
TypeScript introduces this feature to provide better encapsulation and access control during development, but it does not affect the runtime behavior of JavaScript.
In the example provided above, properties like name
and content
are declared and initialized in the constructor, but it's not mandatory to do so.
Instead, you can also do shorthand initialization:
class Package {
private content: string[] = [];
constructor(private title: string, private weight: number) {
this.title = title;
this.weight = weight;
}
getDetails(): string {
return `Title: ${this.title}, Weight: ${this.weight} kg`;
}
addContent(newContent: string){
this.content.push(newContent);
}
}
Readonly properties
Kind of related to what you just learned about access modifiers, is another modifier. And that's the readonly
modifier.
Let's say we have certain fields, which should not just be private or public, they also shouldn't change after their initialization.
For example, I can do something like this, change the title :
class Package {
private content: string[] = [];
constructor(private title: string, private weight: number) {
this.title = title;
this.weight = weight;
}
getDetails(): string {
return `Title: ${this.title}, Weight: ${this.weight} kg`;
}
addContent(newContent: string){
this.title = "I changed title!";
this.content.push(newContent);
}
}
const p = new Package("my package", 4)
console.log(p.getDetails()); // "Title: my package, Weight: 4 kg"
p.addContent("book");
console.log(p.getDetails()); // "Title: I changed title!, Weight: 4 kg"
So title
should not change thereafter and :
To make it clear that it shouldn't change, you can add readonly
in the constructor as well:
class Package {
private content: string[] = [];
constructor(private readonly title: string, private weight: number) {
this.title = title;
this.weight = weight;
}
getDetails(): string {
return `Title: ${this.title}, Weight: ${this.weight} kg`;
}
addContent(newContent: string){
this.title = "I changed title!";
this.content.push(newContent);
}
}
If you try to change the title, now you will get an error:
Cannot assign to 'title' because it is a read-only property.
Now the readonly
keyword, just like private
and public
, is introduced by TypeScript, it does not exist in JavaScript.
Marking a property as readonly
in a class, TypeScript ensures that any attempt to write to that property after its initial value has been assigned will result in a compilation error.
This adds an extra layer of safety to ensure that certain properties should not be modified after their initial value has been set, which is a common requirement for certain properties in objects.
It helps in preventing unintentional modifications to such properties and makes the code more robust and maintainable.
Inheritance
So client decided to offer customers package delivery for extra large packages which is very similar to the normal package but the weight is always over 50+ kilograms and they need special documentation and reports to proceed with shipping and import costs.
For situations like this, we can use inheritance.
class LargePackage extends Package {
private reports: string[] = [];
constructor(title: string, weight: number) {
super(title, weight);
}
addReport(newReport: string) {
this.reports.push(newReport);
}
printReports() {
console.log(this.reports);
}
}
const largePackage = new LargePackage("Large Package", 60);
largePackage.addReport("Package weight measured for 60 kgs");
Inheritance is a mechanism in object-oriented programming where a class inherits properties and methods from its parent class. In TypeScript (and JavaScript), a class can only inherit from one parent class, and this is referred to as single inheritance.
When you create a constructor in a class that inherits from another class, you need to use the super
keyword to call the constructor of the parent class. This allows you to execute the constructor of the base class before initializing any properties or performing other actions in the derived class's constructor.
The super
keyword followed by parentheses is used to call the constructor of the parent class.
It's important to note that you must call super
before using the this
keyword in the constructor
of the derived class. Like this:
constructor(title: string, weight: number) {
super(title, weight);
this.stamps = [];
}
This ensures that the base class constructor is executed first and any initialization done by the base class constructor is completed before the derived class's constructor is executed.
It is because the derived class inherits properties and methods from the base class, and those properties and methods need to be properly initialized before using them in the derived class.
Override and Protected modifier
In TypeScript, a strongly-typed object-oriented programming language, you can override methods and properties from a parent class in a subclass.
Method overriding is a concept where a subclass can provide its own implementation for a method that has already been defined in its parent class, allowing for customization of behavior in the subclass while maintaining the inheritance hierarchy.
Let's say that we need to handle some special cases in the addContent
method for LargePackage
class.
We can override that method and implement that logic:
class Package {
protected content: string[] = [];
constructor(private readonly title: string, private weight: number) {
this.title = title;
this.weight = weight;
}
getDetails(): string {
return `Title: ${this.title}, Weight: ${this.weight} kg`;
}
addContent(newContent: string){
this.content.push(newContent);
}
}
class LargePackage extends Package {
private reports: string[] = [];
constructor(title: string, weight: number) {
super(title, weight);
}
addContent(newContent: string) {
if(newContent === "alcohol"){
console.log("we can not ship alcohol in large packages");
return;
}
this.content.push(newContent);
}
addReport(newReport: string) {
this.reports.push(newReport);
}
printReports() {
console.log(this.reports);
}
}
Private properties are only accessible from within the class in which they are defined, and not in classes that inherit from that class.
For example, the content
property is only accessible inside the Package
class and not in the LargePackage
class.
However, if we want to grant access to the content property in subclasses while still preventing external changes, we can switch it to protected
.
This keyword is similar to private
, but it allows access not only within the class but also in any class that extends from the class with the protected
property.
Getters and setters
In TypeScript, getters and setters are powerful features that provide enhanced control over class properties.
These functions are defined within a class and are used to retrieve or update the values of properties.
Getters retrieve the value of a property, while setters update the value of a property. The main advantage of using getters and setters is that they enable greater control over the way values are accessed and modified in a class.
For instance, getters can dynamically calculate values based on other class properties, while setters can enforce constraints on the values being set.
In TypeScript, getters and setters can be defined using the get
and set
keywords, followed by the name of the property that they are associated with:
class Package {
protected content: string[] = [];
get packageWeight() {
return this.weight;
}
set packageWeight(newWeight: number) {
if (newWeight < 0.1) {
console.error("A weight must be at least 0.1kg weight!");
}
this.weight = newWeight;
}
constructor(private readonly title: string, private weight: number) {
this.title = title;
this.weight = weight;
}
getDetails(): string {
return `Title: ${this.title}, Weight: ${this.weight} kg`;
}
addContent(newContent: string) {
this.content.push(newContent);
}
}
const p = new Package('package', 2);
p.packageWeight = 5;
console.log(`Weight is ${p.packageWeight}`); // prints "Weight is 5 kg"
Notice how getters and setters are used, without braces (like in functions).
Through the utilization of getters and setters, developers can establish consistent and controlled access and modification of class properties, thereby reducing the likelihood of bugs and enhancing code maintainability.
In summary, getters and setters are potent tools in TypeScript that contribute to improved readability, maintainability, and robustness of object-oriented code.
Static methods
Static methods in TypeScript are class-associated methods, not tied to any specific class instance.
They can be invoked directly on the class
, without the need for instance creation. Static methods are commonly employed for utility functions or factory methods that operate on or return class instances or as a single point of access for frequently used functionality.
To define a static method in TypeScript, the static
keyword is used in the method declaration, denoting that it belongs to the class
itself, rather than any specific instance.
Unlike instance methods, static
methods cannot access instance properties or methods, but they can access other static
properties and methods. Static methods are called using the class name, followed by the method name.
We can define a static method called create
that returns a new instance of the Package
class.
We can then call this method using Package.create()
, without the need to create an instance of the Package
class first:
class Package {
protected content: string[] = [];
static MAX_WEIGHT = 45;
get packageWeight() {
return this.weight;
}
set packageWeight(newWeight: number) {
if (newWeight < 0.1) {
console.error("A weight must be at least 0.1kg weight!");
}
this.weight = newWeight;
}
constructor(private readonly title: string, private weight: number) {
this.title = title;
this.weight = weight;
}
getDetails(): string {
return `Title: ${this.title}, Weight: ${this.weight} kg`;
}
addContent(newContent: string) {
this.content.push(newContent);
}
static create(title: string, weight: number){
return { title, weight };
}
}
const p = Package.create('package', 2);
console.log(p); // prints "{ title: "package", weight: 2 }"
console.log(`Max allowed weight is ${Package.MAX_WEIGHT}`); // prints "Max allowed weight is 45"
Static methods in TypeScript are a powerful tool that can help to provide a more flexible and modular code structure, by allowing commonly used functionality to be accessed in a single location.
Abstract classes
Abstract classes in TypeScript are designed as class blueprints that cannot be directly instantiated but rather serve as a foundation for other classes to inherit from.
These abstract
classes can include abstract
methods, which are declared but not implemented within the abstract class itself.
The purpose of abstract methods is to serve as placeholders for methods that must be implemented by any subclass that inherits from the abstract class.
Unlike regular methods that can be overridden in subclasses, abstract methods are used to enforce the implementation of specific methods in subclasses.
Abstract classes and methods are useful when you want to mandate the implementation or overriding of certain methods in classes that inherit from a common abstract class.
These methods act as markers that require developers to implement the method in their subclass, ensuring a consistent interface across related classes while allowing for unique behavior in each subclass.
To define an abstract class in TypeScript, the abstract
keyword is used in the class declaration. Abstract methods are defined by including the abstract
keyword in the method declaration, followed by the method signature.
Abstract methods do not have an implementation in the abstract class itself but must be implemented by any subclass that inherits from the abstract class.
abstract class Package {
protected content: string[] = [];
constructor(private readonly title: string, private weight: number) {
this.title = title;
this.weight = weight;
}
getDetails(): string {
return `Title: ${this.title}, Weight: ${this.weight} kg`;
}
abstract addStamp();
addContent(newContent: string) {
this.content.push(newContent);
}
}
class ImportedPackage extends Package {
protected stamps: string[] = [];
constructor(title: string, weight: number) {
super(title, weight);
}
addStamp(stamp: string){
this.stamps.push(stamp);
}
}
const p = new ImportedPackage("test", 2);
console.log(p.getDetails())
When a subclass inherits from an abstract
class in TypeScript, it is required to implement all of the abstract methods that are defined in the abstract class.
This ensures that each subclass provides an implementation for the abstract methods, fulfilling the contract set by the abstract class.
Abstract classes and methods in TypeScript not only facilitate a common interface for related classes but also offer a powerful tool for creating flexible and modular code. They allow for a consistent structure to be defined across multiple classes while still permitting each subclass to have its own unique behavior.
In addition to providing a common interface, abstract classes, and methods can also be utilized to enforce design patterns and best practices. For instance, the Template Method pattern can be implemented using an abstract class and abstract methods.
The abstract class establishes the overall structure of the algorithm, while the abstract methods define the individual steps that must be implemented by each subclass.
This ensures that the algorithm is consistently implemented across different subclasses, while still allowing for customization and adaptability.
Singletons and private constructors
In TypeScript, private constructors are constructors that can only be accessed within the class itself. They are commonly used in conjunction with the Singleton design pattern, which guarantees that a class has only one instance throughout the entire application.
By setting the constructor as private, other classes are prevented from creating additional instances of the class, and the Singleton pattern is enforced through a static method that returns the single instance of the class.
To implement a Singleton in TypeScript, the class must have a private constructor and a static method that returns the sole instance of the class.
The private constructor ensures that no other instances of the class can be created, while the static method provides a way to access the single instance of the class. The static method may also contain logic to create the instance if it has not been created already.
Singletons are useful in situations where only one instance of a class is needed throughout the entire application, such as for managing application configuration or state.
class Package {
private static instance: Package;
private packageName: string;
private constructor(packageName: string) {
this.packageName = packageName;
// Private constructor to prevent instantiation from outside the class
}
public static getInstance(packageName: string): Package {
if (!Package.instance) {
Package.instance = new Package(packageName);
}
return Package.instance;
}
public logInfo(): void {
console.log(`Package: ${this.packageName}, logging some info.`);
}
}
// Usage
const packageInstance1 = Package.getInstance("PackageA");
const packageInstance2 = Package.getInstance("PackageB");
console.log(packageInstance1 === packageInstance2); // Output: true, as both instances are the same
packageInstance1.logInfo(); // Output: "Package: PackageA, logging some info."
packageInstance2.logInfo(); // Output: "Package: PackageA, logging some info." (Same instance as packageInstance1)
In general, employing private constructors and the Singleton pattern in TypeScript can be a potent technique for effective state management and maintaining a single instance of a class throughout an entire application.
Nevertheless, it is vital to exercise discretion and utilize this pattern judiciously, specifically in scenarios where a Singleton is genuinely appropriate.
Over-reliance on the Singleton pattern can result in code that is challenging to test and maintain, thus it's crucial to explore other design patterns and alternatives when they are more suitable.
In the next article, we will talk about interfaces, so stay tuned!
Comments ()