Typescript Fundamental Concepts [Part 2]
Hey there! Welcome to Part 2 of this TypeScript Fundamental Concepts course. If you still need to read the previous article, I encourage you to check it out here. In that article, we covered concepts like Basic Types, Advanced Types, Type Guards, and Project Setup. To get the most out of this part, it's a good idea to read the previous article first, as we'll dive into more advanced topics.
In this article, we'll explore the following areas:
By the end of this article, you'll be well-equipped to implement your Classes, leveraging key Object-Oriented Programming concepts such as inheritance, polymorphism, method overloading, and more. You'll also gain a deeper understanding of advanced types and type guards, empowering you to prevent bugs and build more robust applications in the future.
So, let's get ready to learn more of these advanced Typescript concepts and techniques!
Classes
Classes are among the most commonly asked concepts in technical interviews today, so it's essential to understand them well. if you need to become more familiar with how classes work in JavaScript. In that case, you can read the documentation here, which will help you understand what we'll explain below about using TypeScript classes.
Classes in TypeScript work in the same way as in JavaScript, but with the added advantage of being able to use typing. Let's take a look at a simple example of what a typical class in JavaScript would look like (without typing yet):
We can see that the class has a constructor with its respective parameters for name, last name, and age, as well as three methods that return these properties. To take advantage of TypeScript's benefits, we will add the corresponding types to each of these properties. This will result in something like the following:
Up to this point, we have a class with three attributes, each defined with its respective data type. The constructor's parameters are also defined with their respective types. However, since we are discussing classes in TypeScript, let's take the opportunity to learn about the support that TypeScript provides for Object-Oriented Programming concepts such as Encapsulation, Inheritance, and Polymorphism.
Encapsulation
In TypeScript, encapsulation is achieved by using access modifiers. These are keywords used to define the visibility of properties and methods of a class. Access modifiers include public, private, and protected. Public properties and methods are accessible from anywhere. Private properties and methods can only be accessed from within the class. Protected properties and methods can be accessed within the class and its subclasses.
These access modifiers are the "traditional" ones. However, TypeScript also provides two additional access modifiers: read-only and static. All of these access modifiers can allow or restrict the read, write, or execution access we have to the attributes or methods of an object, depending on the context in which we are operating. We can use them when declaring a class, for example:
As we can see in line 2, we have declared the MAX_AGE property as static and read-only. In lines 14 and 21, we access it through Person, which refers to the class itself and not a particular instance.
Then, in line 23, we create an instance of the Person class, and when trying to access its properties in the following lines, we will notice that we can only access the firstname property since it has a public access type. On the other hand, we will get access errors for the lastname and age properties, as they have a more restrictive access level (private and protected, respectively).
Constructors
Moving on to the topic of constructors in a class, one of the advantages of using Typescript is that we can overload constructors and functions. This means we can have multiple declarations for the same function, and its behavior will depend on the parameters the function receives. For example, if we overload the constructor method, we would have something like this:
We can see several constructor declarations in this image, where each one accepts different parameters, with the last one (line 9) implementing the object's constructor method. This means that we can create our Person object using any of the constructors we have declared, and at the moment of doing so, we will see the following:
In this case, as you can see, the editor is indicating that we can create our object using the second constructor method, which only accepts the parameter name. We can also see that the editor shows that we can choose from 3 different constructor methods, as indicated by "1/3". We can click on the up and down arrows to view the other available constructor methods.
In this other case, as we can notice, this method indicates that it accepts a first name and lastname, as this is how we have defined it in our class. And if we go to the third constructor:
When using external libraries or modules written in TypeScript, you may notice that, in some cases, tooltips with different options for using a particular function, method, or constructor are generated as you interact with them. Depending on your implementation goals, you can choose the appropriate function option.
If you want to create your constructor overloads, it is essential to note that the constructor implementing the overload must account for all cases. For this reason, in Picture 5, you will see that the constructor (line 9) has all its parameters set as optional. This is done to accommodate any overload options we have defined. Moreover, within the constructor's logic, some conditions check whether a parameter exists before assigning it as a value to the class property.
Constructors shorthands
In some cases, we may come across ?empty? constructors. These constructors are shorthand methods that help us save code. They allow us to create an instance of a class based solely on the received parameters. Let's assume our class is declared as follows:
This code looks simple, but we can achieve the same result using the following approach:
As you can see, we condensed the class into a single line using the constructor shorthand. It is essential to declare the access type for each parameter to avoid access errors for these attributes. This occurs because when compiling to JavaScript, if the constructor does not have the specified access types, it will not generate the necessary code for proper functionality. To understand it better, let's examine a more specific example of this issue.
On one side, we have our TypeScript class, and if you notice, when compiling to JavaScript, we get an empty function, so our constructor will not work. On the other hand, if we add the access types, let's see what happens:
As you can see, the constructor function in JavaScript now has the respective assignments for each parameter. Sometimes, checking these types of issues with tools that convert your TypeScript code to JavaScript is helpful, as we did just now. This way, we can understand what is happening behind the scenes that we don't see in TypeScript.
Inheritance
Typically, when working with object-oriented programming, we often reuse methods or attributes from one class because a new class we want to create shares many of its properties. In React, for example, all our components extend from React.Component when we use classes. This is because the React.Component class has properties and methods that we will use in our components, such as render, componentDidMount, componentDidUpdate, etc.
However, we won't explain this using React; instead, we will simplify it using the Animal and Dog classes as examples. Let's see how each of these classes would be coded:
The above is the Animal class with a speak method and some properties. Then, the following is the Dog class with the same structure.
As you can see, both classes are identical. The only difference is the class name, but we can also notice that these classes are related since a dog is, in fact, an animal. In this case, we could first use inheritance to establish the relationship between both classes and, secondly, reuse their attributes and methods. Let's see how the Dog class would look like by extending the Animal class:
As simple as that, we have extended the Dog class, and now we can use it as an Animal. Now, conceptually speaking, the Animal class is referred to as the ?Base Class? or ?Parent Class?, and the Dog class is called the ?Subclass? or ?Child Class?. In the Dog subclass, we can add new properties and methods without losing those inherited from the parent class. Moreover, we can perform method polymorphism, which is quite helpful if we want to change the behavior of an inherited method. For example:
You will notice that the speak method has been overridden, changing its functionality; now, instead of ?Speaking?, it says ?Woof woof?. Also, note that in the constructor of our Dog class, we call the super method, which essentially uses the parent class's constructor to initialize those attributes. It is important to emphasize that if we add new properties to the Dog class, we must create a constructor calling the super method to preserve the parent class's attributes, which we would otherwise lose.
That concludes the explanation of classes and their properties. If you would like me to expand on any of the concepts mentioned above, please do not hesitate to leave me a message, and I will gladly update the article to supplement the information. We will move on to the next topic to continue with the course.
Interfaces
Just as when we use type aliases to define object structures, we can do the same with interfaces, as they are essentially interchangeable. Therefore, it doesn't matter whether we choose one or the other, as the functionality is the same. However, there is a slight difference, and it lies in how they can be extended to include more attributes.
Once we define a type, we can no longer override it to add new attributes, as it is already defined. However, with an interface, we can. Although both can be extended using different techniques, this slight difference may influence the choice between one or the other.
Let's take a look at an example:
As we can see, to extend the AnimalType into DogType, we have to use the intersection (ampersand operator ?&?) to concatenate AnimalType with the new structure. However, with interfaces, this can be done as follows:
The ?IAnimal? interface includes the attributes of both declarations as if they had been combined. However, this can be a bit confusing. Having two interfaces with the same name but with different properties doesn't sound very good. Nevertheless, this is not the most "elegant" way to extend interfaces; in my opinion, it is just one of the ways explained in the official documentation.
What I recommend is to extend the interface and create a new one, something like the following:
This way, the responsibilities of each interface are kept separate, naming conflicts are avoided, and consistency is generated between what we want to express: a relationship between the Animal interface and the Dog interface.
Functions
Regarding the use of functions in Typescript, we have already seen some cases of functions, although we have yet to explain this section in detail. Previously, we learned some things related to functions through constructor methods since they are also functions, but now it's time to go a little further and explain some things we have yet to see.
We already know that functions receive parameters, and we can add typing to these parameters, but we also know that functions can return things, right? Fortunately, in Typescript, we can also specify a function's return.
For example:
As we can see in the image above, we have a Person type declaration. Then, on line 8, we have a function that receives an object of type Person, extracts the name and surname, and returns them. To specify the return type, we have to define it after the parameter step, using the same nomenclature that we have been using in all the types we define, by using the colon followed by the type of value we want to define as a return, in this case, we want to return a string.
In some cases, if not almost all, we will not need to specify types since Typescript is smart enough to infer them. However, it is always recommended to define types for parameters and return types of a function whenever possible. Additionally, one of the things that Typescript can do is infer types through context, that is, take advantage of the context of a function to infer the context of the parameters of a callback in an anonymous function. For example:
This is known as ?Contextual Typing?, and it happens precisely because Typescript can recognize the data type being taken by the forEach from the person array and then determine the value type that will be used in the anonymous function. For this reason, Typescript, in this case, does not complain about using the ?toUpperCase? method on a value that we did not specify the type of since it automatically recognizes that it is a string. This works for both arrow functions and function expressions.
领英推荐
Function Overloads
As we saw in constructors, it is also possible to overload functions, which works similarly.
Here, two concepts arise: ?Overload signature? and ?Implementation signature?. We notice that our ?calculate? function is written three times, with the first 2 being our ?Overload signatures?. While the third function is the implementation that corresponds to the function that supports overloading, usually called ?Implementation signature?. The difference between these two types of functions is that overload signatures can be called, while the implementation signature cannot; it only serves to implement the logic of its overloads. Therefore, this function has to be very flexible and generic to support different cases.
In Picture 22, as we see on line 2, we have a function that receives two numeric parameters and returns a numeric value. Then, in the second function (line 5), we have a function that gets an array of 2 numeric values and returns a numeric value. Finally, in the third function (line 8), our implementation function, we have that the first parameter can be a number or an array. This is done so that it works in both cases of overloading, and the ?y? parameter is initialized to 0 if it is not present.
Then, in the logic of the function itself, we validate that if the first parameter is an array, it will multiply the values of that array by 2; otherwise, it will simply return the sum of x + y.
Therefore, we can use this overloaded function in both ways:
This way, we doverload our "calculate" function with two different types of behavior. Function overloading also works for class methods (or constructors, as we saw before).
However, before using these things in your code, I recommend reading this official guide to learn more about function overloading and what you should or should not do.
Mixins
In general, when working with classes, we often need to extend functionality from other classes, and we usually do this through inheritance. However, there are occasions when inheritance is insufficient since we can only extend from one class at a time. This is where we can use mixins, for example, to extend our classes' functionality, allowing us to emulate some multiple inheritances in our classes. To better understand this concept, let's look at the following class as an example:
In the example above, we first create a base class from which we will apply our mixins. Then, we define types for our mixins, allowing us to specify, for example, the arguments they will receive on one side and the properties and methods we will add on the other.
In this case, each mixin will add a new method to the class, one called greeting() and the other called farewell(). These functions are defined to accept any number of parameters of any type.
Next, we have the definition of our mixins, which, for now, the only thing we need to understand is that, on one side, they are functions that will receive a class of type T as an argument (this means it will be of a generic type, a concept that I will explain in part 3 of the course, don't worry about it for now). Then, these mixins will return another completely new class combined with the one received as an argument.
Mixins help create a new class that combines the original class and additional properties/methods defined by the mixin. Multiple mixins can be added simultaneously.
Finally, we create a new class, which we have called Person for this example, and which we will extend from the base class plus all the mixins we have created.
This way, our Person class will have all the properties and methods of the base class, plus all the properties and methods incorporated in each of the mixins.
As you can see, finally, from an instance of our Person class, we can call the greeting() and farewell() methods and assign a value to the name property inherited from the base class.
The flow that is occurring with mixins could be diagrammed as follows:
As you can see, we start from the base class, applying the mixins until we extend the destination class, thus generating a class with all the properties and methods we need.
Modules
Modules are independent pieces of code that allow for reuse in multiple parts of an application. Thanks to this, we can divide our code into several files and thus better organize the code in our application.
It is possible to use modules from ECMAScript 6, where any declaration can be exported and imported, whether they are variables, objects, functions, classes, etc. This is very useful because by having an encapsulated scope, we do not overload the global scope of variables. Additionally, in TypeScript, we can make modules for mixins and type aliases, among other things.
The syntax for exporting is quite simple; we prepend the word export before a specific declaration, for example:
We can also use export default to export a specific declaration by default. The difference between export and export default is that we can have multiple statements using export in the same file, but we can only define one export default.
The way we use these modules from another file is quite simple; we need to use import and put the path of the file, for example:
As you can see, we have ?myClass? and ?myFunction? being exported in index.ts and ?myVariable? being exported by default. On the other hand, in the file anotherFile.ts, we are importing ?myVariable? without using curly braces because it was exported by default. However, for myClass and myFunction, we must use curly braces; otherwise, we would not be able to access them. Note that using export default is optional.
We can also use aliases, both for exporting and importing, for example:
It may happen that we do not want to import each thing individually, so we can use an asterisk to import the entire module into a single variable, for example:
There are many more things we can do regarding this topic. If you'd like to learn more about this, you can visit the official documentation.
Type Guards (Continued)
In Part 1, we talked about Type Guards. As we saw earlier, Type Guards allow us to make additional validations to our types, where we could see how to use ?typeof? and ?in?.
In this case, we will talk about Discriminated Unions, a pattern used mainly when working with objects or classes and making it easier for us to identify what type a particularsd object is. To understand this pattern, let's look at the following example:
As you can see, we have two types of vehicles: ?Bicycle? and ?ElectricScooter?; each has different properties. However, we have a type called ?Vehicle?, which can be either a bicycle or an electric scooter. Additionally, we have a function that receives this vehicle as a parameter. Inside it, we need to validate what type our parameter is to be able to use its properties and methods. In this case, we are validating if one of the properties exists in the vehicle to use that property (pedalSpeed) or, otherwise, to use the other (motorSpeed).
While this code may work for this example, what if we had more vehicles? We would have to have many more conditions, and the code would become unmanageable. This is where we can use discriminated unions, which are nothing more than additional properties that we define in our interfaces to assign them a specific type. This will enable us to utilize features such as autocomplete and easily identify the type of object being used.
Let's see an example:
As you can see, we've added property called ?type?, which can only be of the defined types ?bicycle? or ?electricScooter?. We've also replaced our if-else conditional with a switch statement, allowing us to quickly check which kind of object we're working with.
This makes the code much tidier and enables us to add multiple vehicles without worrying about handling complex conditions to identify them. Additionally, thanks to autocomplete, we can effortlessly determine its properties and methods once we've identified the object type.
Regarding the bicycle, we can access the pedalSpeed that we defined earlier. And for the electric scooter, we can access the motorSpeed, as demonstrated below:
Remember that for this pattern to work, we must define (or identify) a common property, such as "type", and use that property to validate the different object types.
Advanced Types (Continued)
Lastly, as part of advanced types, we would like to discuss Type Casting, generally used when TypeScript cannot identify types independently.
Type casting allows us to change the type of a variable. This is particularly useful when working with DOM elements, for instance, where we need to enforce the kind of an element to perform subsequent validations or operations with that element. Let's take a look at an example below:
As you can see, in the HTML code, we have a button with the ID ?myButton?, and through TypeScript, we're trying to access this button using a querySelector. However, this is where problems can arise, as we operate on that element, but the operation might not be valid.
Let's assume that we selected a button; in this case, there would be no issue, as we can apply a .focus() on a button element. But what happens in cases where the selector returns a different element? For example, a div.
Div elements are not focusable by default, so our .focus() operation could fail.
In this case, we should validate the type of element we've retrieved with the querySelector. We could also check whether the focus method exists within the element. However, there's a simpler approach: Type Casting. This allows us to tell TypeScript that the element will be of a specific type, for example:
In this case, we're telling TypeScript that the element will be of type ?HTMLButtonElement?. This way, we can work with the certainty that our operations will be on an HTMLButtonElement and not something else, which also helps make our code more robust.
However, there's still something missing, especially when dealing with DOM element operations. It's possible that our querySelector doesn't find the element and returns nothing, for example:
To address this issue, we can add an exclamation mark at the end of the selector, which tells TypeScript that the value will not be null.
By doing this, we're telling TypeScript that the value cannot be null or undefined and that it will be of type HTMLButtonElement, as we saw earlier.
In conclusion, throughout this article, we have explored various essential TypeScript concepts, such as Classes, Interfaces, Functions, Mixins, Modules, Type Guards, and Advanced Types. These concepts form the foundation for writing clean, scalable, and maintainable code in TypeScript, ultimately enhancing the development experience and productivity.
As we move on to Part 3 of this series, we'll delve deeper into more advanced topics to enrich your TypeScript knowledge further. The upcoming article will cover:
By covering these advanced topics and more, you'll be well-equipped to tackle complex TypeScript projects and leverage the full potential of this powerful language. Stay tuned for Part 3!!
You've Made It! Thanks for Reading this Mega Article!
I know it was challenging to grasp all the concepts but trust me, you've now gained valuable knowledge and are better prepared to use TypeScript in your applications. Your dedication has paid off, and you've come a long way in mastering the TypeScript fundamentals. But don't stop learning! As you continue exploring, you'll discover even more advanced techniques and best practices to help you write cleaner, more efficient, and maintainable code. Keep up the fantastic work, and remember that every step forward takes you closer to TypeScript mastery!
See you soon!!
Cloud Engineer Senior at Globant | Google Cloud Certified | AWS Certified
1 年thanks Rafael R. Covarrubias for sharing! such a great article ??