6. Advanced Types

Overview

This chapter introduces you to advanced types. You will start with the building blocks of advanced types – type alias, string, and number literals. This will allow you to gain a better understanding as you take on more complex concepts such as union types. You will also learn how you can combine types to build more complex types, such as intersections. Using advanced types, this chapter teaches you how to write code that is easier to understand for yourself and any others working with you or who are inheriting the project. By the end of this chapter, you will be able to build advanced types by combining primitive types, such as strings, numbers, and Booleans, with objects.

Introduction

In the previous chapter, we went over interfaces and inheritance. You saw how they allowed for the extension and modeling of your classes. Interfaces give your classes structure, and inheritance allows you to extend and build on your existing code.

As web applications become more complex, it is necessary to be able to model that complexity, and TypeScript makes that easy with advanced types. Advanced types allow you to model the complex data you will be working with as a modern web developer. You will be able to take primitive types and make more complex types from them, creating types that are conditional and flexible. This will allow you to write code that is easy to understand and therefore easier to work with. As a working developer, you may come across a dataset provided by an API that you need to integrate into your application. These datasets can be complex. For example, Cloud Firestore from Google is a document-based, real-time database that can have objects nested within objects. With advanced types, you can create a type that is an exact representation of the data coming from the API. This will provide much more context to your code, which, in turn, will make it easier to work with for you and your team. You will also be able to stack complexity by building simpler types and stacking them to make more complex types.

In this chapter, we will cover the building blocks of advanced types – type aliases and type literals. Once we learn how to build types, we will move on to more advanced concepts, including intersection, union, and index types. All these concepts will help you to learn how to use advanced types to add context and abstract complexity to code.

Type Aliases

Type Aliases allow you to declare references to any type – advanced or primitive. Aliases make our code easier to read by allowing us to be less verbose. Aliases allow you, the developer, to declare your type once and reuse it throughout your application. This makes working with complex types easier and your code more readable and maintainable.

Let's say, for example, we are working on a social networking application and we needed to provide an administrator user type for users to manage the pages they created. Additionally, we also need to define a site administrator user. On a base level, they are both admins, and therefore the types would have some commonality between them. With a type alias, we could create an admin type as shown in Figure 6.1, with common properties an admin user would possess and build upon that admin when creating our site admin and user admin types. Aliases allow you to mask the complexity of your code, which will make it easier to understand. Here we have a diagram of an alias that assigns the Admin alias to an admin type, which is a complex type object. We also have an example of an alias, One, that is assigned to a type, number, which is a primitive type:

Figure 6.1: Alias assigning a complex admin type alias

Figure 6.1: Alias assigning a complex admin type alias

Consider the following code snippet:

// primitive type assignment

type One = number;

In the preceding example, we have created an alias, One, that can be used as a type for any number, as it is assigned to the type number.

Now, consider the following code snippet:

// complex (object assignment)

type Admin = {

    username: string,

    email: string,

    userId: string,

    AllowedPages: string

};

Here, we have created an Admin alias, which we have assigned to an object that represents the common properties of a typical administrator, in the context of this example. As you can see, we have created a reference to a type object, which we can now use in our code instead of having to implement the object each time.

As you can see in the preceding diagram and code snippet, type aliases work in a similar way to variable assignments, except a reference is created for a primitive type and/or an object. This reference can then be used as a template for your data. This will allow you to take advantage of all the benefits of a strongly typed language, such as code completion and data validation.

Before we go into our first exercise on type aliases, we will look at some examples of primitive and complex assignments.

Let's say you are working on a class method that takes numbers as arguments, and only numbers. You want to make sure that when your method is used, only numbers are passed as arguments and the right error messages are shown to the user if any other type is passed.

First, we need to create a number type alias with the following syntax:

type OnlyNumbers = number;

The type keyword is followed by the alias, OnlyNumbers, and then the number type.

Now we can build a class with a method that only takes numbers as an argument and use the type alias to enforce our rule:

// instance of numbers only class

class NumbersOnly {

    count: number

    SetNumber(someNumber: OnlyNumbers) {

        this.count = someNumber

    }

}

Now, let's instance our class and pass some arguments to our method to see whether our code works.

For this example, let's try and assign a string as the argument type:

// class instance

const onlyNumbers = new NumbersOnly;

// method with incorrect arguments

onlyNumbers.SetNumber("15");

In the preceding code snippet, we have provided the wrong argument of the string type and this will result in a warning because our method, SetNumber, is expecting a number. Also, by providing your type aliases with meaningful names such as onlyNumbers, you can make your code easier to read and debug. For this example, the section of the code with the problem is highlighted, and when you hover over the error, you get a very helpful error message telling you what the issue is and how it can be resolved:

Figure 6.2: Error message in VS Code

Figure 6.2: Error message in VS Code

This is the case provided that you have the correct support from your IDE. If you don't have IDE support, you will be shown an error message at code compilation.

This is a simple use case, but as your applications become larger, some time has passed, or you are working in a large team, this kind of type security is vital to writing code that is free of mistakes.

Let's consider another example: Say you are working on an online store application and you need to use a product class that was not created by you. If the person who created the class made use of types and used descriptive names, it would be easier for you to work with that code.

Now, let's edit the first example with the correct argument type:

// method with correct arguments

onlyNumbers.SetNumber(15);

In the preceding code snippet, we have provided the correct argument type of number and your class method takes the argument with no issues.

Now, let's consider a complex alias assignment.

For example, we want to create a new function that takes a user object as a type argument. We could define the object as the function argument inline, as shown here:

// function and type definition

function badCode(user: {

    email: string,

    userName: string,

    token: string,

    lastLogin: number

}) {}

In the preceding snippet, the code creates a function that takes a user as an argument, but the type is defined in the function itself. While this would work, let's say you were using the object in a few places in your code, then, you would have to define this object each time. This is very inefficient and, as a good developer, you don't want to repeat code. This way of working will also lead to errors; it will make your code harder to work with and update as every instance of the User type will need to be changed throughout your code. Type aliases resolve this by allowing you to define your type once, as we will demonstrate in the following code snippet.

In much the same way as we have defined our primitive type, we have defined our User type. We use the type keyword, but now we have mapped to an object that is a template of our User type. We can now use the User alias, rather than having to redeclare the object every time we need to define the User type:

// object / complex type User

type User = {

    email: string,

    userName: string,

    token: string,

    lastLogin: number

};

As you can see, we have created a type with the alias User. This allows you to make a single reference to this object type and reuse it throughout your code. If we did not do this, we would have to reference the type directly.

Now you can build a new function using your User type:

// function with type alias

function goodCode(user: User){}

As you can see, this code is much less verbose and easy to understand. All your code regarding the User type is in one location, and when changes are made to the object, all aliases are updated. In the following exercise, you will implement what we have covered so far to build your own type alias.

Exercise 6.01: Implementing a Type Alias

In this exercise, we will use our knowledge of types to build a function that creates products. Let's say, for example, you are working on a shopping application and when the inventory manager adds a product to the inventory, you need to push that product to your array of products. This exercise demonstrates a few ways in which type aliases can be useful by allowing you to define your Product model once and reuse it throughout your code.

Now, in an actual inventory management application, you might have a frontend page that allows a user to input the product name and supporting information manually. For the purpose of this exercise, let's assume the products you want to add are named Product_0 through to Product_5 and all have a price of 100, while the number of each of these products added to the inventory is 15.

This may not be truly reflective of an actual scenario in an inventory management application, but remember, our key goal is to use a type alias. So for now, a simple for loop to complete the aforementioned tasks will suffice:

Note

All files in this chapter can be executed by running npx ts-node filename.ts on the terminal. The code file for this exercise can be found here: https://packt.link/EAiHb.

  1. Open VS Code and create a new file named Exercise01.ts.
  2. Create a primitive type alias, Count, that is of the number type. Count will be used to keep track of the number of products:

    //primitive type

    type Count = number;

  3. Create an object type alias, Product, that is of the type object. Re-use Count to define the count of the product. The Product type alias will be used to define every product we add to our inventory. The properties are common across all products:

    // object type

    type Product = {

        name: string,

        count: Count, //reuse Count

        price: number,

        amount:number,

    }

  4. Declare a products variable of the Product type array:

    // product array

    const products_list: Product[] = [];

    In order for us to make use of the Product type, it was first assigned to a variable in the preceding code, and the product_list variable is an array of objects of the Product type.

  5. Create a function that adds products to the array. Re-use the Product type alias to validate the argument input:

    // add products to product array function

    function makeProduct(p : Product ) {

        products_list.push(p); // add product to end of array

    }

  6. Use a for loop to create product objects of the Product type and add them to the products array:

    // use a for loop to create 5 products

    for (let index = 0; index < 5; index++) {

        let p : Product = {

            name: "Product"+"_"+`${index}`,

            count: index,

            price: 100,

            amount: 15

        }//make product

        makeProduct(p);

    }

    console.log(products_list);

  7. Compile and run the program by executing npx ts-node Exercise01.ts in the correct directory in which this file is present. You should obtain the following output:

     [

      { name: 'Product_0', count: 0, price: 100, amount: 15 },

      { name: 'Product_1', count: 1, price: 100, amount: 15 },

      { name: 'Product_2', count: 2, price: 100, amount: 15 },

      { name: 'Product_3', count: 3, price: 100, amount: 15 },

      { name: 'Product_4', count: 4, price: 100, amount: 15 }

    ]

In this exercise, you created two type aliases, which in turn created references to your actual types.

This allowed you to reduce complexity and make your code more readable, as now you can provide names that have additional context with descriptive names such as Product and products_list. If we were to write this code without the use of aliases, at every place where you used your aliases in the exercise, you would have to define the object or the type directly. This might not be much of an issue here with this simple function, but keep in mind how much more code you would need to build a class or a major project.

As we proceed to more complex type structures, this knowledge will become invaluable. We will continue to build on our knowledge in the next section as we cover type literals.

Type Literals

Type literals allow you to create a type based on a specific string or number. This, in itself, is not very useful, but as we move on to more complex types such as union types, their use will become apparent. Literals are straightforward, so we will not spend a lot of time on them but you will need to understand the concept of literals as we move into the next phase.

Let's start by creating our string and number literals.

We will begin with a string literal:

Example01.ts

1 // string literal

2 type Yes = "yes";

The preceding code creates a Yes type that will take only a specific string, "yes", as the input.

Similarly, we can create a number literal:

3 // number literal

4 type One = 1;

Here, we create a number literal type, One, that will only take 1 as the input.

The basic syntax as observed in the preceding examples is quite simple. We start with the type keyword, followed by the name (alias) of our new literal, and then the literal itself, as shown in the preceding syntax. We now have a type of the yes string and the number 1.

Next, we will build a function that will make use of our new types:

5 // process my literal

6 function yesOne(yes: Yes, one: One ) {

7 console.log(yes, one);

8 }

We have cast our function arguments to our literal types, and because our types are literal, only the "yes" string or the number 1 will be accepted as arguments. Our function will not take other arguments. Let's say we passed "" and 2 as arguments (yesOne("", 2)). You will notice the following warning in VS Code:

Figure 6.3: IDE warning when incorrect arguments are passed

Figure 6.3: IDE warning when incorrect arguments are passed

Now, let's say we passed "yes" and 2 as arguments. Again, you will get the following warning:

Figure 6.4: Errors displayed when a parameter that cannot be assigned is passed

Figure 6.4: Errors displayed when a parameter that cannot be assigned is passed

The preceding are some examples of error messages you might expect if you provide the wrong arguments. The error messages are clear and tell you precisely what you need to do to resolve the error. As you can see, even though we are passing a string and a number, we still get a type error. This happens because these arguments are literal; they can only match themselves exactly.

Now, let's try and pass the correct arguments:

9 // function with the correct arguments

10 yesOne("yes", 1);

Once provided with the correct arguments, the function can be called without any issue, as shown in the following output:

yes 1

Before we move on to intersection types, let's quickly complete a simple exercise to cement our knowledge of string and number literals.

Exercise 6.02: Type Literals

Now that we have a better understanding of literals, let's go through a small exercise to reinforce what we have covered. Here we will create a function that takes a string literal and returns a number literal:

Note

The code file for this exercise can be found here: https://packt.link/hHgNa.

  1. Open VS Code and create a new file named Exercise02.ts.
  2. Create a string literal type, No, and assign the string "no" as the value. Also, create a number literal and assign 0 as the value:

    type No = "no"

    type Zero = 0

  3. Build a function that takes the "No" literal and prints it to the console:

    function onlyNo(no: No):Zero {

        return 0;

    }

  4. Console out the function call results:

    console.log(

        onlyNo("no")

    )

    This will result in the following output:

    0

Literals by themselves are not very useful, but when used in combination with more complex types, their usefulness will become apparent. For now, you need to understand how to create literals, so you can make use of them later in this chapter. In the next section, we move on to intersection types. All the work that we have completed so far will help as we make use of type aliases and literals.

Intersection Types

Intersection Types allow you to combine types to form a new type with the properties of the combined types. This is useful in cases where you have an existing type that does not, by itself, address some data you need to define, but it can do so in combination with another existing type. This is similar to multi-class inheritance, as the child object can have more than one parent object that it derives its properties from.

Let's say you have a type A with a name and age property. You also have a type B with a height and weight property. In your application, you find that there is a need for a person type: you want to track the user's name, age, height, and weight. You can intersect type A and B to form a Person type. Why not just create a new type you ask? Well, this takes us back to wanting to be good coders and good coders stay DRY – Don't Repeat Yourself. Unless a type is truly unique in your application, you should reuse as much code as possible. Also, there is centralization.

If you need to make changes to any of the type code for Person, you just need to make the changes in A or B. This is also a bit limiting as there may be cases where type A is used by more than one object, and if you make changes, it will break the application. With intersection, you can simply create a type C with the changes and update your Person type. You can also merge types with common properties.

Consider a situation where you have a name property in A and also in B. When the types are intersected, you would now have just one name property; however, the merged properties must not only be the same in name, but should also be of the same type, otherwise the types will not merge and will result in errors.

If this is not clear, let's look at a property, age. This can be a number in one type and a string in another. The only way you could intersect these types would be to make the properties common, as either would need to be a string or number.

Imagine that as part of an e-commerce project, you are required to build a shopping cart object that derives its properties from a Product object and an Order object.

The following diagram shows the basic properties of each object and the properties of the new Cart object that is formed using the Product and Order objects:

Figure 6.5: Diagram showing the properties of the Cart object

Figure 6.5: Diagram showing the properties of the Cart object

In the diagram, we have our parent objects, Product and Order, that combine to form a child object, Cart, with all the properties of its parent objects. Please note that we can have more than two parents in an intersection, but for the sake of this explanation, we will stick to two, as this will allow you to grasp the concept faster. In the upcoming example, we will walk through the process of creating our new Cart type in code and a basic use case.

Imagine you are working on the shopping application. You need to create an object to model the product data you will push to the cart for checking out. We already have a Product type for our product data. The Product type has most of what we need to display correct information pertaining to our products on the web page. However, we are missing a few things we require when checking a product out. We will address this not by creating a new type of product, but we will create an Order type with just the properties we need: orderId, amount, and discount, the last of which is optional as it will not always apply.

Here is the code for declaration of the Product type:

Example02.ts

1 // product type

2 type Product = {

3 name: string,

4 price: number,

5 description: string

6 }

7

8 // order type

9 type Order = {

10 orderId: string,

11 amount: number,

12 discount?: number

13 }

In the preceding code snippet, we have created our parent types names Product and Order. Now we need to merge them. This will create the type we need to model our cart data:

14 // Alias Cart of Product intersect Order

15 type Cart = Product & Order;

We build our cart object by assigning an alias, Cart, to our Product and Order types and using & between our two types, as shown in the preceding snippet. We now have a new merged type, Cart, that we can use to model our cart data:

16 // cart of type Cart

17 const cart: Cart = {

18 name: "Mango",

19 price: 400,

20 orderId: "x123456",

21 amount: 4,

22 description: "big sweet, full of sugar !!!"

23 }

The preceding is an example of a cart object declared using the Cart type. As you can see, we have access to all our properties and can omit optional ones that may not always apply, such as discount.

If we do not provide all the required properties, the IDE gives a very helpful error message telling us just what we need to do in order to fix the issue:

Figure 6.6: The error message displayed when missing required properties

Figure 6.6: The error message displayed when missing required properties

Now, let's console out our new cart object: This will display the following output:

{

  name: 'Mango',

  price: 400,

  orderId: 'x123456',

  amount: 4,

  description: 'big, sweet, and full of sugar !!!'

}

In the next section, you will get some hands-on experience in terms of creating intersection types by performing an exercise in which you will build a prototype user management system.

Exercise 6.03: Creating Intersection Types

You are working on an e-commerce application; you have been assigned the task of building out the user management system. In the application requirements, the customer has listed the types of user profiles they expect will interact with the system. You will use type intersection to build out your user types. This will allow you to build simple types that can be combined to make more complex types and separate your concerns. This will result in code that is less error-prone and better supported. Here, we name the user types we will build and provide an overview of their functions:

  • Basic user: This user will have the properties _id, email, and token.
  • Admin user: This user will have the ability to access pages not accessible to a normal user. This user will have the properties accessPages and lastLogin. accessPages is a string array of pages that this user can access, while lastLogin will help us to log the activates of the Admin user.
  • Backup user: This user has the job of backing up the system and the user properties of lastBackUp and backUpLocation. lastBackUp will let us know what time the system was last backed up, while backUpLocation will tell us where the backup files are stored.
  • superUser: This user is an intersection of the Admin and User types. All users require the properties of a Basic user, but only Admin users require Admin properties. Here, we use type intersection to build the necessary properties we need.
  • BackUpUser: This user is an intersection of the Backup user and Basic user types. Once again, we can incorporate into our basic user the necessary complexity this user type requires in order to function.

    Note

    The code file for this exercise can be found here: https://packt.link/FVvj5.

  1. Open VS Code and create a new file named Exercise03.ts.
  2. Create a basic User type:

    // create user object type

    type User = {

        _id: number;

        email: string;

        token: string;

    }

    This will be the type we will use as our base for the other user types in our application. Thus, it has all the common user properties that all users will require.

  3. Create an Admin user type for users who need to perform the functions of an administrator:

    // create an admin object type

    type Admin = {

        accessPages: string[],

        lastLogin: Date

    }

  4. Create a Backup user type for users who are responsible for backing up the application data:

    // create backupUser object type

    type Backup = {

        lastBackUp: Date,

        backUpLocation: string

    }

  5. Using your User and Admin types, declare a superuser object of the User type at the Admin intersect. Add the required properties. In order to create a superuser, you will have to provide values for the properties of User and Admin, as shown in the following code block:

    // combine user and admin to create the user object

    const superUser: User & Admin = {

        _id: 1,

        email: '[email protected]',

        token: '12345',

        accessPages: [

            'profile', 'adminConsole', 'userReset'

        ],

        lastLogin: new Date()

    };

    In an actual application, this code may be in a login function and the values returned might be from an API on login.

  6. Build a BackUpUser type by assigning the alias BackUpUser to the intersection of User and Backup:

    // create BackUpUser type

    type BackUpUser = User & Backup

  7. Declare a backUpUser object of the BackUpUser type and add the requisite properties:

    // create backup user

    const backUpUser: BackUpUser = {

        _id: 2,

        email: '[email protected]',

        token: '123456',

        lastBackUp: new Date(),

        backUpLocation: '~/backup'

    };

  8. Console out your superUser and backupUser objects:

    // console out superUser props

    console.log(superUser);

    // console out backup user props

    console.log(backUpUser);

    This will print the following output:

    {

      _id: 1,

      email: '[email protected]',

      token: '12345',

      accessPages: [ 'profile', 'adminConsole', 'userReset' ],

      lastLogin: 2021-02-25T07:27:57.009Z

    }

    {

      _id: 2,

      email: '[email protected]',

      token: '123456',

      lastBackUp: 2021-02-25T07:27:57.009Z,

      backUpLocation: '~/backup'

    }

In the preceding exercise, you built two user types using the superUser and backupUser intersections that are based on the User, Admin, and Backup types. The use of intersections allows you to keep your core user type simple and can therefore be used as a model for most of your user data. Admin and Backup are intersected with User only when it is necessary to model that specific user case. This is the separation of concerns. Now, any changes made to User, Backup, or Admin will be reflected in all child types. We will now take a look at union types, which is a type functionality. However, unlike intersections, union types provide an OR functionality when types are merged.

Union Types

Union Types are similar to intersections as they are a combination of types to form a single type. Union types differ, however, in that they do not merge your types but provide or type functionality instead of an and type functionality, which was the case with intersection types. This works in a similar way to the ternary operator in JavaScript, where the types you are combining are separated by the | pipe. If this is confusing, it will all become clear as we move on to an example. We will also take a look at type guards, which is a pattern that will play a major role in the app use of union types. First, consider the following visual representation of a union type:

Figure 6.7: Illustration of a union type assignment

Figure 6.7: Illustration of a union type assignment

In the preceding diagram, we have a basic diagram of a union type assignment, where Age can be of the number or string datatypes. You can have union types with more than two options and non-primitive types. This gives you the option to write code that is more dynamic. In the upcoming example, we will extend our age example as mentioned previously and build a basic union type.

Let's say you're working on an application that needs to validate someone's age. You want to write one function that will process ages from a database that are stored as a number and ages from the web frontend that come in as a string. In a case such as this, you might be tempted to use any as a type. However, unions allow us to address this kind of scenario without creating a vector for errors by using any:

Example03.ts

1 // basic union type

2 type Age = number | string;

First, we create a union type, Age, which can be of the number or string datatypes, as shown in the preceding syntax. We assign our Age alias to our types separated by a pipe, |. We could have more than two options, for example, "number" | "string" | "object":

Now we create a function that will make use of the new type, Age, as shown in the preceding snippet:

3 function myAge(age: Age): Age {

4 if (typeof age === "number") {

5 return `my age is ${age} and this a number`;

6 } else if (typeof age === "string"){

7 return `my age is ${age} and this a string`;

8 } else {

9 return `incorrect type" ${typeof(age)}`;

10 }

11 }

The myAge function takes the Age type as an argument and returns a formatted string of the Age type using an if …else loop. We are also making use of a type guard pattern, typeof, which allows you to check the type of your argument. This kind of type checking is necessary while using union types as your argument can be of several types, which, in the case of this preceding code snippet, is a string or a number. Each type will need to be processed with a different logic.

Union types can also be objects; however, in such a case, typeof will not be very useful as it will only return the type, which will always be object. To resolve such cases, you can check for any unique properties of your object and apply your logic in this way. We will see examples of this as we work through our exercise in the next section.

Now, let's get back to the example. To ensure that our functions are working as they should, we console out the results by calling them with different argument types (number and string):

console.log(myAge(45));

console.log(myAge("45"));

This will result in the following output:

my age is 45 and this a number

my age is 45 and this a string

Let's say that you passed an incorrect argument instead:

console.log(myAge(false));

You will see the following error message:

error TS2345: Argument of type 'boolean' is not assignable to parameter of type 'Age'.

Exercise 6.04: Updating the Products Inventory using an API

In the following exercise, we will extend our inventory management example from Exercise 03 by adding an API. This will allow remote users to add and update products in our inventory via an API PUT or POST request.

Since the processes of updating and adding a product are so similar, we will write one method to handle both requests and use a union type to allow our method to take both types and remain type safe. This will also mean that we can write less code and encapsulate all related code to the one method, which will make it easy for us or any other developer working on the application to find and resolve errors.

You could use the any type, but then your code would become type insecure, which could lead to bugs and unstable code:

Note

The code file for this exercise can be found here: https://packt.link/Qvx6D.

  1. Open VS Code and create a new file named Exercise04.ts.
  2. Create three types, Product, Post, and Put, along with the base objects you will require, as shown here:

    type Product = {

        name: string,

        price: number,

        amount: number,

    }

    type Post = {

        header: string,

        method: string,

        product: Product

    }

    type Put = {

        header: string,

        method: string,

        product: Product,

        productId: number

    }

    We first create a product type that will help us to define what format the product data will take as part of a Put or Post request. We have also defined Put and Post, which differ slightly because a Put request will need to update a record that already exists. Note that Put has the property productId.

  3. Create a union type, SomeRequest, which can be either the Put or Post type:

    type SomeRequest = Post | Put

    The data being matched to the union type can be any of the types in the union. Note that unions do not combine types; they simply try to match the data to one of the types in the union, which gives you, the developer, more flexibility.

  4. Create an instance of an array of the Product type:

    const products: Product[] = [];

  5. Build a handler function that processes a request of the SomeRequest type:

    function ProcessRequest(request: SomeRequest ) {

        if ("productId" in request) { products.forEach(

                (p: Product, i: number) => {

                   products[request.productId] = {

                       ...request.product

                   };});

        } else {

            products.push(request.product);

        }}

    This function will receive a request of the Put or Post type and add or update an attached product to the products array. In order to know whether it should update or add the function, it first checks whether the product has a productId argument. If it does, we will loop through the Products array until we find a matching productId argument. Then, we use the spread operator to update the product data with the data from the request. If the product does not have a productId argument, we then just use the push function attached to the array to add the new product to the array.

  6. Declare apple and mango objects of the Product type, as shown here:

    const apple: Product = {

        name: "apple",

        price: 12345,

        amount: 10

    };

    const mango: Product = {

        name: "mango",

        price: 66666,

        amount: 15

    };

    In a real API, the data would be provided by the user sending it via a request, but for the purposes of this exercise, we have hardcoded some data for you to work with.

  7. Declare postAppleRequest and putMangoRequest objects of the Post and Put types:

    const postAppleRequest : Post = {

        header: "zzzzz",

        method: 'new',

        product: apple,

    };

    const putMangoRequest : Put = {

        header:"ggggg",

        method: 'update',

        product: mango,

        productId: 2

    };

    In the preceding code, we have defined our POST and PUT objects. We have attached the product object as a payload of the request. Remember that the function is not checking the product object but the request type, which will tell the function whether it's POST or PUT.

  8. Call the handler function and pass postAppleRequest and putMangoRequest as arguments, as shown in the following code snippet:

    ProcessRequest(postAppleRequest);

    ProcessRequest(putMangoRequest);

    In a normal API, when the user makes a PUT or POST request, the ProcessRequest method would be called. We are, however, just simulating an API and making the calls ourselves.

  9. Console out the results:

    console.log(products)

    You will see the following output:

    [

      { name: 'apple', price: 12345, amount: 10 },

      <1 empty item>,

      { name: 'mango', price: 66666, amount: 15 }

    ]

    In the preceding output, we can now see the products that we passed to our methods. This means that our simulated API code using unions works as intended.

Union types, such as intersection types, give you, the developer, more functionality and flexibility when building your applications. In the preceding exercise, we were able to write a function that takes a single argument of two different types and applies logic based on type checking patterns or type guards. In the next section, we will continue the theme of more code flexibility with index types.

Index Types

Index types allow us to create objects that have flexibility as regards the number of properties they may hold. Let's say you have a type that defines an error message, which can be more than one type, and you want the flexibility to add more types of messages over time. Because objects have a fixed number of properties, we would need to make changes to our message code whenever there was a new message type. Index types allow you to define a signature for your type using an interface, which gives you the ability to have a flexible number of properties. In the following example, we will expand on this in the code:

Example04.ts

1 interface ErrorMessage {

2 // can only be string | number | symbol

3 [msg: number ]: string;

4 // you can add other properties once they are of the same type

5 apiId: number

6 }

First, we create our type signature, as shown in the preceding snippet. Here we have a property name and type, which is the index [msg: number] followed by the value type. The name of the msg argument can be anything, but as a good coder, you should provide a name that makes sense in the context of the type. Note that your index can only be a number, string, or symbol.

You can also add other properties to your index, but they must be the same type as the index, as shown in the preceding code snippet, apiId: number. Next, we make use of your type by casting it to errorMessage. We can now have an error message object with as many properties as we require. There is no need to modify the type as our list of messages grows. We maintain flexibility while keeping our code typed, thereby making it easy to scale and support:

7 // message object of Index type ErrorMessage

8 const errorMessage: ErrorMessage = {

9 0: "system error",

10 1: "overload",

11 apiId: 12345

12 };

Now, we console out the new object just to make sure that everything works:

// console out object

console.log(

    errorMessage

);

You will get the following output once you run the file:

{ '0': 'system error', '1': 'overload', apiId: 12345 }

If we try to give a property name of an incorrect type, such as a string, we get the kind of error message you might expect:

Figure 6.8: Output displaying the type error

Figure 6.8: Output displaying the type error

You can, however, use strings that are numbers, for example, and the code will function as before and the output will be the same:

14 // message object of Index type ErrorMessage

15 const errorMessage: ErrorMessage = {

16 '0': "system error",

17 1: "overload",

18 apiId: 12345 };

You may think that this will not work given that the value is a string, but it gets converted to a number literal. It will also work the other way around using a number literal that gets converted to a string. Next in our exercise, we will simulate the real-world usage of an index type, building a simple system to process error messages.

Exercise 6.05: Displaying Error Messages

In this exercise, we will build a system to process error messages. We will also reuse the ErrorMessage index type we created in our example. The code in this exercise is somewhat contrived but will serve to help you get a better understanding of index types:

Note

The code file for this exercise can be found here: https://packt.link/ZkApY.

  1. Open VS Code and create a new file named Exercise05.ts.
  2. Create the ErrorMessage type interface from our example if you have not already done so:

    interface ErrorMessage {

        // can only be string | number | symbol

        [msg: number ]: string;

        // you can add other properties once they are of the same type

        apiId: number

    }

  3. Build an errorCodes object as an ErrorMessage type, as shown here:

    const errorMessage : ErrorMessage = {

        400:"bad request",

        401:"unauthorized",

        403:"forbidden", apiId: 123456,

    };

  4. Create an error code array as errorCodes, as shown here:

    const errorCodes: number [] = [

        400,401,403

    ];

  5. Loop through the errorCodes array and console out the error messages:

    errorCodes.forEach(

        (code: number) => {

            console.log(

                errorMessage[code]

            );

        }

    );

    Once you run the file, you will obtain the following output:

    bad request

    unauthorized

    forbidden

Index types allow you to have flexibility with your type definitions, as you can see in the preceding exercise. If you need to add new codes, you will not need to change your type definition; simply add the new code property to your errorCode object. Index types work here because even though the properties for the object are different, they all have the same basic makeup – a number property (key) followed by a string value.

Now that you have the building blocks for advanced types, you can work through the following activities. The activities will make use of all the skills you have acquired in this chapter.

Activity 6.01: Intersection Type

Imagine that you are a developer working on a truck builder feature for a custom truck website. You will need to make it possible for customers that come to the site to build a variety of truck types. To that end, you need to build your own intersection type, PickUptruck, by combining two types, Motor and Truck. You can then use your new type, PickUpTruck, with a function that returns the type and validates its input with the PickUpTruck intersection type.

Note

The code file for this activity can be found here: https://packt.link/n4tfL.

Here are some steps that will help you to complete this activity:

  1. Create a Motor type, which will house some common properties you may reuse on their own or in combination with other types to describe a vehicle object. You can use the following properties as a starting point: color, doors, wheels, and fourWheelDrive.
  2. Create a Truck type with properties common to a truck, for example, doubleCab and winch.
  3. Intersect the two types to create a PickUpTruck type.
  4. Build a TruckBuilder function that returns our PickUpTruck type and also takes PickUpTruck as an argument.
  5. Console out the function return.
  6. Once you complete the activity, you should obtain the following output:

    {

      color: 'red',

      doors: 4,

      doubleCab: true,

      wheels: 4,

      fourWheelDrive: true,

      winch: true

    }

    Note

    The solution to this activity is presented via this link.

Activity 6.02: Union Type

A logistics company has asked you to develop a feature on their website that will allow customers to choose the way they would like their packages to be shipped – via land or air. You have decided to use union types to achieve this. You can build your own union type called ComboPack, which can be either the LandPack or AirPack type. You can add any properties to your package types that you think will be common to a package. Also, consider using one type literal to identify your package as air or land, and a label property that will be optional. You will then need to construct a class to process your packages. Your class should have a method to identify your package type that takes arguments of the ComboPack type and uses your literal property to identify the package type and add the correct label, air cargo or land cargo.

Note

The code file for this activity can be found here: https://packt.link/GQ2ZS.

Here are some steps that will help you to complete this activity:

  1. Build a LandPack and an AirPack type. Make sure to have a literal to identify the package type.
  2. Construct a union type, ComboPack, which can be LandPack or AirPack.
  3. Make a Shipping class to process your packages. Make sure to use your literal to identify your package types and modify your package with the correct label for its type.
  4. Create two package objects of the AirPack and LandPack types.
  5. Instantiate your Shipping class, process your new objects, and console out the modified objects.

    Note

    The solution to this activity is presented via this link.

Activity 6.03: Index Type

Now that you have done such a good job of incorporating the shipping option into the website, the company now needs you to add a feature that will allow their customers to track the status of their packages. It is important to the client that they have the ability to add new package statuses as the company grows, and as shipping methods change, they would like that flexibility.

Hence, you have decided to build an index type, PackageStatus, using an interface signature of the status property of the string type and a value of the Boolean type. You will then construct a Package type with some common package properties. You will also include a packageStatus property of the PackageStatus type. You will use PackageStatus to track three statuses of your package: shipped, packed, and delivered, set to true or false. You will then construct a class that takes an object of the Package type on initialization, contains a method that returns the status property, and a method that updates the status property, which takes status as a string and Boolean as a state.

The method that updates your package should also return your packageStatus property.

Note

The code file for this activity can be found here: https://packt.link/2LwHq.

Here are some steps that will help you to complete this activity:

  1. Build your PackageStatus index type using an interface with a property of status of the string type and a value of the Boolean type.
  2. Create a Package type that includes a property of the PackageStatus type and some common properties of a typical package.
  3. Make a class to process your Package type that takes the Package type on initialization, has a method to return your packageStatus property, and a method that updates and returns the packageStatus property.
  4. Create a Package object called pack.
  5. Instantiate your PackageProcess class with your new pack object.
  6. Console out your pack status.
  7. Update your pack status and console out your new pack status.

    The expected output is as follows:

    { shipped: false, packed: true, delivered: true }

    { shipped: true, packed: true, delivered: true }

    Note

    The solution to this activity can be found via this link.

Summary

In this chapter, we covered advanced types, which allow you to extend beyond your basic types. As applications become more complex and the frontend takes on more functionality, your data models will also become more complex. This chapter showed you how TypeScript advanced types give you the ability to implement strong typing, which will help you develop cleaner and more reliable applications. We covered the building blocks of advanced types – type aliases and literals, and we then moved on to intersection, union, and index types with some practical examples, exercises, and activities.

You now have the ability to create complex types that will allow you to build types for modern applications and write code that is well supported and scalable. Having reached this point, you now have the tools to take on web frameworks, such as Angular2 and React. You can even use TypeScript on the server side with Node.js. There is much more to advanced types and the topic is quite vast, complex, and abstract in its implementations. However, here in this chapter, you have been equipped with the skills you need to start building applications with advanced types.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset