This post is taken from my blog, so be sure to check it out for more up-to-date content ๐
In the previous article, I talked about TypeScript and why it's worth learning. I covered topics like primitive types, top types, unions, function, type guards etc., so if any of these phrases are unfamiliar to you, I recommend you to check the previous post first. If not, then that's good, because I'm going to heavily rely on the knowledge passed from the first part. In this tutorial, we're going to explore some more complex and more interesting TS structures and functionalities. I'll introduce you to interfaces, classes and number of other TS goods that will definitely improve your development experience, comfort, and IDE support. Without further ado, let's begin! Enjoy!๐
Type aliases
Back in the first article, we were discovering a great many new types. So-called by me composition types had especially long syntax. Imagine you'd have to use some kind of the same union type multiple times, over and over again. Not only it requires a lot of typing, but also isn't very DRY and thus makes your code a bit messy. How to fix this? Well, TypeScript provides you with some help - type aliases. As the name suggests type aliases allow you to assign a different name to specified type.
type MyUnionType = string | number;
const myUnionVariable: MyUnionType = "str";
const myUnionVariable2: MyUnionType = 10;
Your type alias serves as a constant which you can assign your type to. To specify one yourself, you must use type keyword, choose a name, and assign a type to it. ๐ Just like with a normal variable! Then you can reference your type through aliased name just as you'd do with normal types anywhere you want. One thing to note about naming tho. It's a good and popular practice to start your types' names with a capital letter. This makes them different from standard variables.
With proper name, type alias can also serve as better documentation for your type. Imagine a union type of string literals. The assigned name would provide a whole another level of description. IDE should also discover you alias and display its name instead of long union type whenever you'd use it.
Classes
I expect that by 2019 every JS developer knows what ES6 and ES-Next are and what features they bring to the table. As I mention in the first article, TypeScript is a superset (static type system) of ES-Next, which means that its compiler can transpile some of the ES-Next syntactic features down to older ES versions for better cross-browser support. These features include e.g. classes(already well-supported in most modern browsers) and decorators (Stage 2 proposal at that time). I won't be covering these exclusively as they're probably well-known and generally more JS-related. If you want you can read more about them here and here. We, instead, will focus on the features that TypeScript adds to classes, because, yup, there are many! ๐ฎ
Class members
As we already know, in TS everything has to have a type. This includes class members. Before accessing any member using this.
syntax, you need to first declare our member.
class MyClass {
myStringMember: string = 'str';
myBooleanMember?: boolean;
constructor() {
this.myStringMember; // 'str'
this.myNumberMember = 10; // error
}
}
If you won't declare a property earlier, you'll get an access error. Declaration of class member is nothing more than specifying its name and type, inside the given class as in the example above. Optionally, you can also assign a default value for your member, right at the moment of its declaration. Another thing you can use is the optional sign (?
), effectively making your member not-required. Both of these methods make it not needed to assign any value to a particular member in the constructor.
Modifiers
Being a statically-typed language, TS borrows many ideas from other similar languages. One of which being access modifiers. To use them, you need to specify particular modifier's respective keyword before your class member.
class MyClass {
private myStringMember: string;
protected myNumberMember: number;
public constructor() {
this.myStringMember = 'str';
this.myNumberMember = 10;
}
}
You can use these with properties, methods and even the constructor (with some limits). It's very important to remember that these modifiers only provide information for TS compiler and IDE, but, as TS is transpiled to JS, there's no difference between members with different modifiers. JS doesn't provide any option to change class members' accessibility and thus all members are publicly accessible in outputted code. ๐คจ
Public
The default modifier, if there's no directly specified one. Indicates that given member can be accessed publicly, meaning both outside and inside of given class.
class MyClass {
public myStringMember: string = 'str';
public constructor() {
this.myStringMember; // 'str'
}
}
new MyClass().myStringMember; // 'str'
It's also one of the two modifiers that can be applied to the constructor (and is by default). Public constructor allows your class to be instantiated anywhere in your code.
Private
Private modifier limits the accessibility of class member to only inside of the class. Accessing it outside will throw an error. It follows the OOP principle of encapsulation ๐, allowing you to hide information that isn't required outside of given scope.
class MyClass {
private myStringMember: string = 'str';
constructor() {
this.myStringMember; // 'str'
}
}
new MyClass().myStringMember; // error
In general, this technique is very useful. Too bad that there's no direct equivalent of it in JS. And although there is a proposal for that, for now, closures seems like the only alternative. That's why in the output of TS compiler, all members are publicly accessible anyway.
Protected
Protected modifier serves as a middle-ground between the private and public one. Protected members are accessible inside the class and all of its derivatives (unlike private).
class MyClass {
protected myStringMember: string = 'str';
protected constructor() {
this.myStringMember; // 'str'
}
}
class MyInheritedClass extends MyClass {
public constructor() {
super();
this.myStringMember; // 'str'
}
}
new MyClass(); // error
const instance = new MyInheritedClass();
instance.myStringMember; // error
The snippet above should give you a proper understanding of what's going on. Note, that protected modifier can be used with the constructor. It effectively makes your class uninstantiable, meaning you cannot create an instance of it just like that. What you must do is to create a class that inherits from the previous one, (which makes protected constructor accessible in there) but with a public constructor. That's a nice trick, but not really useful. If you want to have a class that's used only to inherit from, then it might be better to use abstract classes, which we'll talk about later.
Again, the concept of modifiers should be nothing new to those who programmed in e.g. Java or C# before. But, as we're talking JS here, this brings a whole new level of possibilities to make our software architecture better. ๐
Beyond accessibility modifiers, TS provides us 2 more (TS v3.3): readonly
and static
. Although static
is a part of JS (surprise), readonly
is not. As the name suggests, it allows indicating a particular member as, obviously, read-only. Thus, making it assignable only when declaring and in the constructor.
class MyClass {
readonly myStringMember: string = 'str';
constructor() {
this.myStringMember = 'string'
}
myMethod(): void {
this.myStringMember = 'str'; // error
}
}
readonly
modifiers are applicable only to properties (not methods or constructor) using the proper keyword. Also, remember that readonly can be used together with other accessibility modifiers in particular order.
As for the static
modifier, it works by making the given member accessible on the class rather than its instance. Also, static members cannot access and be accessed by this. Instead, you can access your class member by directly referencing its name e.g. MyClass
. Static members allow you to e.g. define cross-instance constants or use class as a collection of various methods.
class MyClass {
static myStringMember: string = 'str';
constructor() {
this.myStringMember // error
MyClass.myStringMember // 'str'
}
static myMethod(): void {
this; // error
}
}
Abstract classes
Earlier in the post, I mentioned the abstract classes. What are these? Well, abstract classes are nothing more than classes that cannot be instantiated by themselves and thus, serve only as a reference for other, inherited classes. As for the syntax, everything new that comes with abstract classes is the abstract
keyword. It's used to define the class itself and its particular members.
abstract class MyAbstractClass {
abstract myAbstractMethod(): void;
abstract myAbstractStringMember: string;
constructor() {
this.myMethod();
}
myMethod() {
this.myAbstractMethod();
}
}
Above example demonstrates the full potential of abstract classes utilized in a (mostly) proper way. We already know that abstract is used to declare our corresponding class. But what abstract means when used with class members? It denotes the members that inherited class needs to implement on its own. If no proper implementation is found, an error will be thrown. Any other, already implemented members are normally inherited by respective classes. ๐
class MyClass extends MyAbstractClass {
myAbstractStringMember: string = 'str';
myAbstractMethod(): void {
// code
};
}
new MyAbstractClass() // error
new MyClass().myAbstractStringMember; // 'str'
Declaration time
When declaring your class, in reality, you're doing 2 things - creating the instance type of given class and so-called constructor function.
Created instance type allows you to define the variable's type as an instance of a particular class. You can use this type like any other, utilizing the name of your class.
const instance: MyClass = new MyClass();
Constructor function, on the other hand, is what's called when you're creating an instance of the given class, with the new
keyword.
But what if you want to assign the constructor function itself rather than an instance to a variable. In JS you would just write something like this:
const MyClassAlias = MyClass;
But what's the actual type of classAlias
when written in TS? Here comes the typeof
keyword, previously known to us only as a type guard. It allows you to take the type of any JS values to later utilize it. So, to answer the question:
const MyClassAlias: typeof MyClass = MyClass;
const instance: MyClass = new ClassAlias();
Now, for the last trick, how often do you use constructor arguments to assign some class members? It's so common use-case that TS provides a shortcut for this particular occasion. You can precede your argument with any accessibility or readonly modifier so that your argument can become a full-fledged class member. Pretty interesting, isn't it? ๐
class MyClass {
constructor(public myStringMember: string) {}
myMethod(): void {
this.myStringMember;
}
}
Interfaces
Now, that we have TS classes well-covered, it's time to explore interfaces! ๐ Interfaces are a gold standard of many statically-typed languages. They allow you to define and work with the shape of value, rather than the value itself.
Interfaces are commonly used to describe the shape of complex structures, like objects and classes. They indicate what publicly available properties/members the end structure need to have. To define one you must use interface
keyword and proper syntax:
interface MyInterface {
readonly myStringProperty: string = 'str';
myNumberProperty?: number;
myMethodProperty(): void
}
Inside the interface declaration, we can use previously learned TS syntax, more specifically read-only and optional properties, and default values. Interfaces can also include methods that our future structures will need to implement.
One of the main use-cases of the interfaces is as a type. You can use it with already known syntax.
const myValue: MyInterface = {
myStringProperty: "str";
myMethodProperty() {
// code
}
}
Interfaces also allow you to describe values such as functions and class constructors. But, there's a different syntax for each, respectively:
interface MyFunctionInterface {
(myNumberArg: number, myStringArg: string): void;
}
interface MyClassInterface {
myStringMember: string;
}
interface MyClassConstructorInterface {
new (myNumberArg: number): MyClassInterface;
}
When it comes to interfaces, you can utilize them to create different types that will help you to type the flexibility of JS. That's why you can join the interfaces above with other properties to create so-called hybrid types. ๐
interface MyHybridInterface {
(myNumberArg: number, myStringArg: string): void;
myNumberProperty: number;
myStringProperty: string;
}
This interface, for example, describes a function which has 2 additional properties. This pattern is maybe not really popular, but very much possible in dynamic JavaScript.
Inheritance
Interfaces just like classes can extend each other and classes' properties too! You can make your interface extend one, or even more (not possible in classes) interfaces with simple extends keyword syntax. In this case, properties shared by extended interfaces are combined into single ones.
interface MyCombinedInterface extends MyInterface, MyHybridInterface {
myBooleanProperty: boolean;
}
When an interface extends a class, it inherits all of the class members, no matter what accessibility modifier they use. But, modifiers are taken into account later, when your interface can be implemented only by the class which has given the private member, or its derivatives. This is the only time when accessibility modifiers interact with interfaces. Otherwise, there's no possibility and need of them to exist with interfaces, which only describe the shape of values. ๐
interface MyCombinedInterface extends MyClass {
myBooleanProperty: boolean;
}
Classes
Interfaces and classes share a special bond. From their declaration syntax alone you can see the similarities. That's because classes can implement interfaces.
class MyClass implements MyInterface {
myStringProperty: string = 'str';
myNumberProperty: number = 10;
}
By using the implements
keyword, you indicate that given class must have all properties implemented as described in a particular interface. This allows you to later define your variables more swiftly.
const myValue: MyInterface = new MyClass();
Remember the class constructor interface? That's the point where things get a little more complicated. When we were talking about classes, I mentioned that when defining a class, you're creating the instance type (called instance side) and constructor function (called static side). When using implements
you're interacting with the instance side. You're telling the compiler that the instance of that class should have properties from this interface. That's why you cannot write something like this:
class MyClass implements MyClassConstructorInterface {
// code
}
That's because this would mean that the instance of that class can be instanced by itself. Instead what you can do, is to use the class constructor interface to describe what class you need e.g. as an argument. Maybe a complete example can showcase it better. ๐
interface MyInterface {
myStringProperty: string;
}
interface MyClassConstructorInterface {
new (myNumberArg: number): MyInterface;
}
class MyClass implements MyInterface {
myStringProperty: string = 'str';
constructor(myNumberArg: number ){}
}
function generateMyClassInstance(ctor: MyClassConstructorInterface): MyInterface {
new ctor(10);
}
generateMyClassInstance(MyClass);
A quick description of what's going on. First, we declare 2 interfaces - one for the instance side, defining the shape of MyClass
instance, the other, for the static side, defining the look of its constructor. Then we define the class with proper implements statement. Finally, we make use of MyClassConstructorInterface
to define the shape of required class constructor (static side) that can be passed to our function to be later instantiated.
Modules
A really quick note here. ๐ By now you're probably familiar with what ES6 modules are, aren't you? In TypeScript, the standard [import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import)
/[export](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export)
keywords, beyond normal JS values, can be used with type aliases, enums, interfaces etc. This allows you to properly divide your code into smaller, easier-to-maintain chunks. The syntax and general rules remain the same.
export interface MyInterface {
myStringProperty: string = 'str';
myNumberProperty?: number;
}
Enums revisited
In the previous article, we've talked about enums as a way of giving nicer names to numeric data. But, unsurprisingly, enums have a lot more functions than just that. ๐
Enums, besides numbers, can be composed of strings. In this case, each member must have a constant string value assigned to it. All other enum-related rules apply.
enum MyStringEnum {
A = 'str1',
B = 'str2',
C = 'str3',
}
In theory, if all members are directly assigned, you can freely mix string and numeric values in your enums. It doesn't have a real use-case though.
Enums can also be used at runtime, as object-like structures. In addition, you can assign not only static values but also computed/calculated ones to enums members. So that the assignment below is fully correct.
const myNumber: number = 20;
enum MyEnum {
X = myNumber * 10,
Y
};
const myObject: {X: number, Y: number} = MyEnum;
When compiled, enums take a form of a JS object. But, if you want your enum to serve just as a collection of constant values, you can easily achieve that with the const keyword.
const enum MyEnum {
X,
Y
}
In such constant enums, you cannot include computed members as we've done before. These enums are removed during compilation, thus leaving nothing more than constant values in places they were referenced.
Back to functions
We have already talked about functions quite a bit. But, because we want to know more, it's time to take a look at some more complex aspects. ๐
Default values
Just like with class members, default values can also be assigned for function parameters. There can be multiple arguments with default values, but there cannot be any required argument, without a default value afterward. Only when no argument is passed, the default value is used.
function myFunc(myNumberArg: number, myDefaultStringArg: string = 'str') {
// code
}
myFunc(10);
myFunc(10, 'string');
This
With the introduction of arrow functions and better .bind()
method specification introduced in ES6, handling of this
in functions became much easier. But still, how to type this
of a normal function? Unless you use .bind()
or do something similar, TS can most likely handle itself well, with built-in type inference. Otherwise, you need to specify this
parameter.
type Scope = {myString: string, myNumber: number};
function myFunc(this: Scope, myStringArg: string = 'str') {
this.myString;
this.myNumber;
}
myFunc(); // error
myFunc.bind({myString: 'str', myNumber: 'number'});
With this
parameter provide, TS compiler makes sure that this context of your function is correct and throws an error in other cases.
As for arrow functions, there's no option for this
parameter. Arrow functions cannot be boundas they use the preassigned this value. Thus, any attempt to assign this parameter will throw an error.
Overloads
Overloads allow you to define different functions, sharing the same name, but with different arguments set. It's commonly used when you need to accept different types of arguments and handle them exclusively in one function.
function myFunc(myArg: number): string;
function myFunc(myArg: string): number;
function myFunc(myArg): any {
if(typeof myArg === 'number'){
return 'str';
}
if(typeof myArg === 'string'){
return 10;
}
}
When declaring overloads, you simply provide multiple function signatures, after which you define your actual function with more general types (like any in the example). The compiler will later choose the right override and provide proper information to IDE. Naturally, the same technique can be used within e.g. classes.
Rest parameters
Yet another popular feature that came with ES6 is the rest parameter and the destructurizationoperator. TS provides good support for both of these features. TypeScript allows you to type the rest parameter just like any other:
function myFunc(myNumberArg: number, ...myRestStringArg: string[]) {
// code
}
myFunc(10, 'a', 'b', 'c');
As for the destructurization, TS type inference does its job just fine.
Cliffhanger
Wow, we've covered quite a lot, don't you think? With classes and interfaces, you can now start doing some OOP programming in TS yourself. Believe me or not, statically-typed languages are much better when it comes to utilizing OOP and its principles. Anyway, there's still much to discuss. We haven't yet talked about generics, indexed types, declaration merging, and other even more complex stuff. So, stay tuned for that by following me on Twitter and on my Facebook page. Also, if you liked the article, please ๐ฑ, share it, so other people can learn about TypeScript and this blog too! ๐ And finally, don't forget to leave your reaction below and maybe even a comment of what you'd like to see next!
That's it... for now. ๐
Resources
Now, that you know a bit more about TS, it's time to broaden your knowledge. Go, read, code and learn and come back for part III! ๐
- TypeScript official docs from typescriptlang.org;
- Write Object-Oriented JavaScript with TypeScript from rachelappel.com;
- TypeScript cheatsheet from devhints.io;
Top comments (1)
Really like your posts, they are astonishingly refreshing ๐ Just amazing how you already know so much! ๐ค