Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / Typescript

100 Days of TypeScript (Day 10)

4.00/5 (1 vote)
9 Jun 2022CPOL9 min read 5.4K  
In the previous article, I started describing how I had built a more complex TypeScript web application that retrieves data from a separate API and displays the data in a relatively visually pleasing manner.

In the previous article, I started describing how I had built a more complex TypeScript web application that retrieves data from a separate API and displays the data in a relatively visually pleasing manner. We saw how we could name interface elements to cope with more complicated names, as well as introduce index signatures. Finally, we looked at how we could use the fetch API to retrieve data from the remote site, paying attention to the use of the Promise type to cope with asynchronous code.

In this article, we are going to finish breaking the application down. The code for the application can be found here.

Building the view

In the last article, we laid the groundwork for creating a table that would be populated at runtime. As a recap, our table is built up like this.

JavaScript
<table class="table table-striped">
    <thead class="table-dark">
        <tr>
            <th scope="col">Time</th>
            <th scope="col">Open</th>
            <th scope="col">High</th>
            <th scope="col">Low</th>
            <th scope="col">Close</th>
            <th scope="col">Volume</th>
        </tr>
    </thead>
    <tbody id="trading-table-body"></tbody>
</table>

The way that I am going to populate the table is to create a separate View class that is responsible purely for adding the rows into the column.

The first method I want to cover here is the one that actually adds a row and populates the cells.

JavaScript
private addTableRow(tableBody: HTMLTableSectionElement, ...elements: string[]): void {
    const tableRow = tableBody.insertRow(tableBody.rows.length);
    elements.forEach(element => {
        const columnElement = tableRow.insertCell(tableRow.cells.length);
        columnElement.textContent = element;
    });
}

This method accepts an instance of an HTMLTableSectionElement. This is a roundabout way of saying it takes the table body as the area we are going to add a row onto. I am going to pass the items we want to display in each cell in a row using the spread operator (...elements: string[]).

Now that I have everything I need to add the rows, I am going to add a row to my table body using the insertRow function. This method does exactly what it says, it inserts a row at a certain index so it is tempting to think that there would also be an addRow function to add a row at the end of the table body. While it might be tempting, the reality is, there is only an insertRow function so, to add the row at the end, we have to insert the row using the number of rows we already have, using tableBody.rows.length, as the guide where to put the row.

I now need to add each element in as a cell. To add a cell, I follow a similar pattern to the row using insertCell. I set the textContent to the relevant element and this will be displayed when the row is rendered in the able.

Having created the code I need to add a row to the table, I need a method that will take the output from the trading API and call the addTableRow method.

JavaScript
Build(shareDetails: Trading): void {
    const tableBody = document.getElementById('trading-table-body') as HTMLTableSectionElement;
    Object.entries(shareDetails['Time Series (5min)']).forEach(([date, timeSeries]) => {
        this.addTableRow(tableBody, date, timeSeries['1. open'], 
            timeSeries['2. high'], 
            timeSeries['3. low'], 
            timeSeries['4. close'],
            timeSeries['5. volume']);
    });
}

The first part of this method should be apparent by now. I get the HTML element that has the id trading-table-body and cast it to an HTMLTableSectionElement (this is what a tbody is in TypeScript). I’m going to use something new here to get the entries out of the API. If you remember, from the last article, each trading element is given a unique name. In order to iterate over these entries, I need to use the Object.entries operation which returns an array of key/value pairs from the object. The key/value pairs are used in the forEach function and are represented a bit like this ([key, value]). In my forEach, I have tried to be a bit more meaningful and use descriptive names date and timeSeries to represent the key and value. With this capability, I can call the addTableRow method, passing in the table body, the date, and the time-series values.

This is time for a confession. In my first iteration of the code for this article, I put the id on the table itself. This meant that the code I was writing to populate the table became more convoluted than it needed to be because I had to get the table body after I got the table. This was an unnecessary step that was easily eliminated by moving the id onto the tbody element.

If you were trying to write this code, from scratch, and you were just using the default tsconfig.json file, you would not be able to use the Object.entries option. To use this capability, you need to add a minimum of ES2017 in your lib entry.

A brief journey into tsconfig.json

In the last article, and this one, I have mentioned changes that need to be made to tsconfig in order for the code to build. As I have said previously, the tsconfig file controls how the code is built, and what it looks like. It is time to take a look at the tsconfig file for this code to see what effect it has on the build operations.

JSON
{
  "compilerOptions": {
      "target": "es2017",
      "module": "amd",
      "removeComments": true,
      "noImplicitAny": true,
      "lib": [
          "es2017",
          "dom"
      ],
      "outDir": "./scripts",
  }
}

Starting at the bottom, outDir tells TypeScript where it will write the output js files to. The lib entry tells TypeScript what libraries to load to compile the code. As I am making use of Output.entries and HTML elements such as HTMLTableSectionElement, I have to add the es2017 and dom libraries.

One of the strengths of TypeScript is that it is a great support to you when you want to use types. By setting noImplicitAny, I tell TypeScript that I want to be warned if I have code that it thinks can be of type any because I forgot to put the type in. Why use a type system if you are just going to ignore it?

Depending on the complexity of the system I am working on, I may have comments in my codebase. I do not want these to be added to the compiled code because that wastes bytes when I am serving up the JavaScript. I love the fact that removeComments helps me here.

I’m going to skip to the top of the file for the moment. The target entry is fascinating. What it does is tells TypeScript what version of JavaScript support should be used. This affects the output of the code because it allows us to write code that uses the latest language features and then writes the corresponding JavaScript that would apply at the target version. The reason for this is because TypeScript is often ahead of features that are either being proposed for a future version of JavaScript or which have been adopted, but haven’t been implemented in the major browsers. Obviously, if all the browsers support the features of your code, then there is no need to use workarounds. The easiest way to visualise this is to change the target to es5 and then recompile the code. This is the output.

JavaScript
define(["require", "exports"], function (require, exports) {
    "use strict";
    Object.defineProperty(exports, "__esModule", { value: true });
    exports.View = void 0;
    var View = (function () {
        function View() {
        }
        View.prototype.Build = function (shareDetails) {
            var _this = this;
            var tableBody = document.getElementById('trading-table-body');
            Object.entries(shareDetails['Time Series (5min)']).forEach(function (_a) {
                var date = _a[0], timeSeries = _a[1];
                _this.addTableRow(tableBody, date, timeSeries['1. open'], timeSeries['2. high'], timeSeries['3. low'], timeSeries['4. close'], timeSeries['5. volume']);
            });
        };
        View.prototype.addTableRow = function (tableBody) {
            var elements = [];
            for (var _i = 1; _i < arguments.length; _i++) {
                elements[_i - 1] = arguments[_i];
            }
            var tableRow = tableBody.insertRow(tableBody.rows.length);
            elements.forEach(function (element) {
                var columnElement = tableRow.insertCell(tableRow.cells.length);
                columnElement.textContent = element;
            });
        };
        return View;
    }());
    exports.View = View;
});

This is a lot of code, and a lot of workaround for features that were added in later versions of JavaScript. If I change the target back to ES2017, this is what the code looks like.

JavaScript
define(["require", "exports"], function (require, exports) {
    "use strict";
    Object.defineProperty(exports, "__esModule", { value: true });
    exports.View = void 0;
    class View {
        Build(shareDetails) {
            const tableBody = document.getElementById('trading-table-body');
            Object.entries(shareDetails['Time Series (5min)']).forEach(([date, timeSeries]) => {
                this.addTableRow(tableBody, date, timeSeries['1. open'], timeSeries['2. high'], timeSeries['3. low'], timeSeries['4. close'], timeSeries['5. volume']);
            });
        }
        addTableRow(tableBody, ...elements) {
            const tableRow = tableBody.insertRow(tableBody.rows.length);
            elements.forEach(element => {
                const columnElement = tableRow.insertCell(0);
                columnElement.textContent = element;
                tableRow.appendChild(columnElement);
            });
        }
    }
    exports.View = View;
});

The final bit of the config file is the module we are using. We can think of a module as simply a collection of files that are linked and that we can use in our code. Any items we export in our code can be used in other parts of our codebase so, as we want our View to be usable in other files in our codebase, it is exported in View.ts. The following picture shows the relationship here, with the browser not having to care about the individual files as TypeScript will have taken care of the relationship.

Image showing module with exported and imported class, and browser accessing the module.

The reason we set the module to AMD is that we want to produce that can be loaded using RequireJS. If we didn’t do this, when we loaded our page we would get this error. Uncaught ReferenceError: exports is not defined. Okay, that seems a little bit confusing so let’s go step by step.

  1. We want to load multiple scripts so we are going to use a module.
  2. If I set my script tag in HTML to this: <script src="scripts/api.js"></script>, I get the error Uncaught ReferenceError: define is not defined in the console.
  3. If I set my script tag to this: <script type="module" src="scripts/api.js"></script> and browse to my file locally, I get the error Access to script at 'file:///…/scripts/api.js' from origin 'null' has been blocked by CORS policy: Cross origin requests are only supported for protocol schemes: http, data, chrome-extension, edge, https, chrome-untrusted.
  4. If I use something like live-server to load my page as a hosted page, I get the following error Uncaught ReferenceError: exports is not defined.
  5. In order to load the multiple scripts, I need to use RequireJS, so my module has to be AMD. My HTML script tag now looks like this: <script src="https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.6/require.min.js" data-main="scripts/api"></script>. With an AMD module, this works perfectly.

Bringing it all together

I have the code to get data from the API. I have code to display it in a table. All I need now is code to bring the two parts together. In the script, I refer to an api file, which contains the calls I want to make to my api and then bind to the display. This file is, cunningly enough, called api.ts. It doesn’t contain a class, simply relying on a couple of function calls to make everything work together.

The way that I am going to start my code here is to create instances of the View and Intraday classes.

JavaScript
import { Intraday } from "./Intraday";
import { Trading } from "./Models/Trading";
import { View } from "./View";
const view = new View();
const intraday = new Intraday();

Things get a little bit more interesting at that point. If you remember, from the last article, the Get method is an asynchronous method, returning a Promise. The code for a Promise can be a little bit awkward so what I want to do is use a different way of working with promises. There is a feature called async/await that gives us the same capability as doing Promise().then(). Let’s look at what our code looks like using async/await.

JavaScript
async function RefreshView(symbol: string): Promise<void> {
    const shareDetails = await intraday.Get(symbol) as Trading;
    view.Build(shareDetails);
}

In order to say I want this piece of code to be asynchronous, I use the async keyword. Any async function returns a Promise because this is just wrapping up a promise. Having said that my code is going to be asynchronous, I use await to say that I want to wait for the Get call to finish before continuing on to the Build call. If you think back to the description of promises, you can see that the code that comes after the await is the code inside the then part of the promise.

Finally, I need a piece of code to call RefreshView. As the function is asynchronous, I am going to use the then block to update the loading state header to say that the data has been loaded.

JavaScript
RefreshView('MSFT').then(x => {
    const state = document.getElementById("loading-state") as HTMLHeadingElement;
    state.textContent = `Data loaded for MSFT`;
});

You might wonder why I didn’t use the following code to refresh the view.

JavaScript
await RefreshView('MSFT')
const state = document.getElementById("loading-state") as HTMLHeadingElement;
state.textContent = `Data loaded for MSFT`;

The reason I use the promise format rather than the async version is because of the module we are using. As we have set the module to amd, we would get the error Top-level 'await' expressions are only allowed when the 'module' option is set to 'esnext' or 'system', and the 'target' option is set to 'es2017' or higher. With this minor limitation in mind, the simple promise format is enough for me.

Conclusion

There has been a lot to take in, in this article. We have covered changes to tsconfig and how to use async features of the language, as well as looking at how to dynamically add entries into tables. The next post sees us adding additional TypeScript language features into our arsenal.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)