- Describe the common TypeScript features.
- Use TypeScript features.
In this lesson, we will go over the following TypeScript features that will be most useful as we use TypeScript to learn Angular and build our first Angular application.
- Primitive Types:
number
,string
andboolean
- Complex Types: objects and arrays
- Type Inference
- Union Types
- Type Aliases
- Generics
- Classes
- Interfaces
- Decorators
Another advantage of TypeScript is that since it needs to be compiled to JavaScript anyway, the compiler can also take care of converting modern features of JavaScript into less modern, but more widely supported, versions of JavaScript.
As previously discussed, TypeScript supports assigning a type to a variable, including the following primitive types you might have seen in other languages:
number
: this specifies that the variable can be any number. TypeScript does not make a distinction between integers and decimal numbers or between decimal numbers of various precision.string
: this specifies that the variable will hold textboolean
: this specifies that the variable will hold a value oftrue
orfalse
The type of a variable is specified by adding : <type>
after the variable
definition. Here are a couple of examples:
let name: string = "example";
let age: number = 12;
let studentFlag: boolean = true;
If I try to assign a value of the wrong type to either of these variables:
let personName: string = "Jamie";
let age: number = "12";
let studentFlag: boolean = true;
Not only will the TypeScript compiler give me an error:
simple.ts:35:5 - error TS2322: Type 'string' is not assignable to type 'number'.
35 let age: number = "12";
~~~
Found 1 error in simple.ts:35
But any IDE that supports TypeScript will have the ability to give you an error message in your editor as soon as it sees code that is not consistent with the types that you have indicated:
We can also specify that a given variable can have a more complex "shape" than
simply be made of a single primitive type. For example, we could have a person
variable that must have inside it all three types we worked with earlier:
let person: {
name: string;
age: number;
studentFlag: boolean;
};
You can then assign a value to the variable person
and TypeScript will warn
you if the value doesn't have the required fields or if any of the fields are
not of the right type:
person = {
age: 20,
name: "Steph",
studentFlag: false,
};
We already know the type of message we would get if we tried to set a value to the wrong type. Let's see what happens if we omit a required field:
person = {
age: 20,
name: "Steph",
};
In this case, TypeScript will give you this type of message:
simple.ts:40:1 - error TS2741: Property 'studentFlag' is missing in type '{ age: number; name: string; }' but required in type '{ name: string; age: number; studentFlag: boolean; }'.
40 person = {
~~~~~~
simple.ts:37:5
37 studentFlag: boolean;
~~~~~~~~~~~
'studentFlag' is declared here.
Found 1 error in simple.ts:40
Note something important here - the message
Property 'studentFlag' is missing in type '{ age: number; name: string; }' but required in type '{ name: string; age: number; studentFlag: boolean; }'
suggests that there might be a way to declare that an element of a complex type
may be acceptable but not required.
This is done by adding a ?
after the name of the variable. We can change the
person
declaration as follows:
let person: {
name: string;
age: number;
studentFlag?: boolean;
};
Which now means the code that assigns a value that does not include a
studentFlag
value is actually valid.
What if I wanted to have another variable with the exact same shape as person
.
Based on our current setup, I would have to give it a name and then specify its
exact shape using the same code as I did for person
. For example:
let anotherPerson: {
name: string;
age: number;
studentFlag?: boolean;
};
This is not great, as this code would have to be copied and pasted everywhere
where I need this type of structure. TypeScript has 2 constructs to help with
this issue: interface
and type
. Let's go over those now.
Consider our person
variable from the previous section:
let person: {
name: string;
age: number;
studentFlag?: boolean;
};
To turn this person into a re-usable structure, we can define its shape using
the interface
feature of TypeScript:
interface Person {
name: string;
age: number;
studentFlag?: boolean;
}
I can declare any variable as needing to follow the structure defined in the
Person
interface:
let person: Person;
let anotherPerson: Person;
And TypeScript will warn me of any use of either the person
or the
anotherPerson
variable that doesn't respect the definition specified in the
Person
interface.
A type is similar to an interface, but varies in some key ways, which we will go over later. For now, let's look at the definition of a type for same "Person" shape we've been working with:
type Person = {
name: string;
age: number;
studentFlag?: boolean;
};
By defining a "Type" with the type
keyword, we now have the ability to re-use
this definition elsewhere, just like we did with our interface
-based
definition:
let person: Person;
let anotherPerson: Person;
2 notes to remember about types as we continue to explore the features of TypeScript:
- The "shape" of an object can be defined through an
interface
or through atype
. Both mechanisms provide the same ability to re-use the definition of that structure in different places. Beyond that, there are difference betweeninterface
andtype
that we will explore a later in this module. - When we refer to a "type" in the rest of this module, we are referring to the
general mechanism of defining the shape of an object, regardless of whether
that definition was declared using the
interface
keyword, thetype
keyword or even theclass
keyword, which we will cover later. For example, "union types", which we will study next, can be used with types that are defined in any of the ways mentioned above.
With "union" types, TypeScript gives us the ability to specify that a variable could be of one type or of another type.
To explore why we might want to do that, let's go back to our function that adds 2 numbers:
function add(firstNumber: number, secondNumber: number) {
return firstNumber + secondNumber;
}
console.log(add(10, 20));
As a reminder, this code compiles correctly because we're passing the right
types into the add()
function, and it gives us the expected result of 30
because the numbers being added are actually numbers instead of strings.
This is not very useful code, however, because it only adds together 2 hardcoded
numbers. Let's make it more useful then, by integrating it with a very simple
HTML form. Create a file named no-types.html
with the following code:
<!DOCTYPE html>
<html lang="en">
<head>
<title>JS has no types</title>
<script src="../dist/add.js" defer></script>
</head>
<body>
<input type="number" id="firstNumber" placeholder="First Number" />
<input type="number" id="secondNumber" placeholder="Second Number" />
<button>Add!</button>
</body>
</html>
This sets up a very basic HTML form with 2 input fields and a button. We will now write TypeScript code that will add the values in the 2 input fields when the button is clicked:
const button = document.querySelector("button") as HTMLButtonElement;
const input1 = document.getElementById("firstNumber") as HTMLInputElement;
const input2 = document.getElementById("secondNumber") as HTMLInputElement;
button!.addEventListener("click", function () {
console.log(add(input1!.value, input2!.value));
});
function add(firstNumber: number, secondNumber: number) {
return firstNumber + secondNumber;
}
console.log(add(10, 20));
Let's walk through this code:
- The first 3 lines of codes are to assign variables to the button and the
input fields. As you can see, we are using the
as
notation to tell TypeScript that we want each element to be of a specific type. This allows TypeScript (and VSCode) to understand the type we're expected to deal with and help us enforce proper usage in the subsequent code. - Next, you will notice that we're using the
!
notation, for example with the following expression:button!.addEventListener(...)
- this is to tell TypeScript that we are confident thatbutton
will never benull
. If we didn't do that,tsc
would complain that thebutton
variable might be null because it has no direct awareness of the HTML form we're getting this reference from. - The
button!.addEventListener()
function call tells the HTML that when this button is clicked (theclick
) parameter thefunction
passed in as the second parameter should be called. That function then takes the values from the first and the secondinput
fields and adds them together using ouradd()
function
With that, the code you just created is syntactically correct, but will still
not compile. Instead, tsc
will give you the following error:
src/add.ts:7:25 - error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
7 console.log(add(input1!.value, input2!.value));
~~~~~~~~~~~~~
Found 1 error in src/add.ts:7
That is because the .value
property of an input
field is actually a
string
, and our add()
function is currently defined as only taking number
parameters.
There are multiple ways to fix this issue. We will address it in a way that
shows how useful union types can be. But first, let's see how we would have to
fix it to make the compiler happy without using union types. We could modify
the add()
function to take parameters of type any
:
function add(firstNumber: any, secondNumber: any) {
return firstNumber + secondNumber;
}
This removes the compiler warning and lets us run our code. However, it also
renders TypeScript's type functionality basically useless because we now have an
add()
function that will accept parameters of any types. This defeats the
purpose of using TypeScript in the first place.
Instead of using the any
type as a workaround, we can be more precise and
actually tell the add()
function that we want it to accept either number
parameters or string
parameters:
function add(firstNumber: number | string, secondNumber: number | string) {
return +firstNumber + +secondNumber;
}
Let's examine this code:
- We are using the
|
notation to indicate thatfirstNumber
can be of typenumber
or it can be of typestring
. Both types are acceptable for this particular variable. We specify the same forsecondNumber
. - Since our variables could now be either
number
orstring
, we need to convert them before we can add them together. That's what the+
in front offirstNumber
andsecondNumber
does - it takes thestring
value and attempts to convert it to a number.
Your code will now compile with the tsc
command and you should be able to open
no-types.html
in your browser window, open your console and see the following
output when you enter numerical values in the input fields and click the "Add!"
button:
Data validation in HTML is a complex subject that is beyond the scope of this particular section, so this example is incomplete. In reality, we would want to make sure that that values the user enters in these input fields are actually numbers and we would want to display an error message if the user entered incorrect values.
A "Union Type" then is a way to tell TypeScript that a variable can be of more
than one type. This is a great alternative to using the any
type, which while
flexible is really negating the value of typing in TypeScript.
TypeScript has the ability to infer the type of a variable based on the value you assign to it. For example:
let x = 3;
Based on this code, TypeScript will infer that the type of x
is number
and
the following code will compile successfully:
function addNumbers(
firstNumber: number | string,
secondNumber: number | string
) {
return +firstNumber + +secondNumber;
}
let x = 3;
console.log(addNumbers(x, 20));
Note that type inference can be tricky because the wrong type could be inferred if the first assignment to the variable is of the wrong type, which would lead to errors that are difficult to track down. It is better practice to be explicit about your types, as that a) is safer for the compiler and b) is more explicit of your intent as the programmer, which is always key to making your code more maintainable over time.
Just as you can define a type for variables, you can also define a type for the return value of functions:
function addExplicit(a: number, b: number): number {
// return type is explicit here
return a + b;
}
Generally, the return type of a function is easier and safer to infer because most functions do not (and should not) have many different return statements and those return statements are usually quite explicit about the values they are constructing.
Multiple return statements in a function make it harder to read and track what the function does. They also make it harder to test. If you are writing a function that seems to lend itself to having many return points, it might be a sign that you're trying to do too much in a single function. Instead, try to break the functionality up into multiple steps and have each step implemented in their own function.
When a function a) does not explicitly define a return type and b) does not have
a return statement, its return type is inferred to be void
, which means the
caller of that function will not get a return value.
Types are great for all the reasons we've been covering in this module. However, they do make it difficult in some scenarios to write code that can be re-used in many different scenarios.
Consider the following use case: we need a function that can take an existing array and add a value at the beginning of that array. We might write something along these lines:
function insertAtBeginning(array: number[], value: number) {
const newArray = [value, ...array];
return newArray;
}
let numbers = [10, 20, 30];
numbers = insertAtBeginning(numbers, 5);
console.log("new array: " + numbers);
The insertAtBeginning()
function works for arrays of numbers, but will not
work for arrays of strings or any other primitive or complex type.
Note: this function used the "spread" operator
...
- this is a TypeScript operator (also available in vanilla JavaScript) that takes the value of an existing array and returns all existing elements of that array. In our example, this means thatnewArray
is initialized to a an array that contains firstvalue
and then every entry in thearray
variable.
One way to change the insertAtBeginning()
function to make it work for "any"
type, of course, would be to change the type of the array and the variable to
any
. But as we've seen before, this takes away our ability to use TypeScript's
type validation functionality.
Generics are a better solution for this problem. Instead of specifying a specific type, we can actually specify a "generic" type, and the caller of the function can decide what type that will be. And once that type is defined, it will have to be consistent throughout that usage of the function.
Let's look at a modified version of the insertAtBeginning()
function that uses
generics:
function insertAtBeginning<AGenericType>(
array: AGenericType[],
value: AGenericType
) {
const newArray = [value, ...array];
return newArray;
}
Let's examine this code:
- The
<>
notation indicates that this function is dealing with generics - We can name our generic anything we want. We're using
AGenericType
here to drive that point home, but most code will usually use a single letter for the generic, so that's what we'll use moving forward to follow that convention. - Once the generic definition is added to the function name, it can be used anywhere inside the function, including it the parameter names
- So now our array is an array of
AGenericType
objects and our new value we want to add to the beginning of the array is also of typeAGenericType
We can now use this function with any type we want:
function insertAtBeginning<T>(array: T[], value: T) {
const newArray = [value, ...array];
return newArray;
}
let numbers = [10, 20, 30];
numbers = insertAtBeginning(numbers, 5);
console.log("new numbers array: " + numbers);
let strings = ["first string", "second string", "third string"];
strings = insertAtBeginning(strings, "new string");
console.log("new strings array: " + strings);
But the following code will, correctly, give us a compilation error because we're trying to combine 2 types when the function signature clearly states that whatever the type of the array is, it needs to be the same type as the type of value we're trying to add to it:
So we can now define functions that are flexible enough to work with any type, but that retain TypeScript's ability to enforce proper usage of types.
We will work with classes more as we dive deep into Angular, but for now let's look at classes as simply another way to define the shape we want objects to take, with the added benefit of being able to assign behavior to those shapes:
class Student {
firstName: string;
lastName: string;
age: number;
private courses: string[]; // this is a private property
constructor(first: string, last: string, age: number, courses: string[]) {
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
this.courses = courses;
}
enrol(courseName: string) {
this.course.push(courseName);
}
listCourses() {
return this.courses.slice();
}
}
This Student
class has several attributes, as we've seen before with
interfaces
, but it also has:
- A constructor: this is a special method that gets called when an object of this type is created. The constructor has the responsibility to initialize the object with its proper values, sometimes based on defaults, sometimes based on values passed into it.
- 2 functions that can be called on any object of this type. These functions can the implement functionality that will be available and standard for all objects of this type.
- A private member variable: this member variable, in this case
courses
will not be accessible outside the code in this class. This allows us to have "internal" state that can be changed without affecting the users of this class.
TypeScript gives us a shorthand for defining properties, including private ones, using the constructor without having to define the member variables separately. The following code is functionally exactly equivalent to the code above:
class Student {
constructor(
public first: string,
public last: string,
public age: number,
private courses: string[]
) {}
enrol(courseName: string) {
this.course.push(courseName);
}
listCourses() {
return this.courses.slice();
}
}