Introduction to TypeScript
We will give a brief introduction to the TypeScript language to develop front-end applications. The way of working with Typescript is much the same as with JavaScript but it benefits from the fact that the syntax is constrained by the declared data types. The existence of data types, even if in this case they are only checked at the transpiling stage where the TypeScript code is transformed into normal JavaScript code, helps developers to avoid common mistakes in poorly typed languages. We will go through the specific syntax of TypeScript, for those familiar with C-like and object-oriented languages the syntax is easy to learn but differs in the way of use compared to these languages.
Variables and constants
In JavaScript/TypeScript, the keywords let and const are used to declare variables and constants, the difference being that variables declared with let can be reassigned. There is also the var keyword, but its use is discouraged because variables declared this way exist in a global scope within the program and not in a local scope, which leads to serious security issues.
const constant = 5;
let value = 10;
value += constant;
console.log("Assigned value is: ", value);
// output - Assigned value is: 15
Types
The types that TypeScript works with are:
- number - numeric values that can be integers, floating point numbers or NAN (Not A Number)
- string - character strings declared either between "" or between ''
- boolean - boolean values with the only values true and false
- array - vectors of any type (string[], number[], boolean[] etc) using [], they come with classic functions map, flatMap, *reduce * etc.
- null - null values represent their own type here to indicate that the value can be null
- undefined - is a special type to indicate the non-existence of a field in an object, function parameter or variable definition
- object - is declared using in which fields with names and related values can be added
- function - in JavaScript/TypeScript both normal functions as data type and lambda functions (also called arrow functions) can be declared
- any - an unsafe type that can be assigned to anything and assigned any value
- unknown - is the safer version of any, it can be assigned any value but it can only be assigned to another unknown variable
- never - is a non-valued type that can be assigned any value, it is used for example to signal that a function never returns and that it throws an exception
- void - is used to show that a function does not return
let booleanValue = true; // the type of the variable is determined by default as boolean
let numericValue: number = 5; // variable type is declared explicitly
let maybeString = booleanValue ? "Hello" : null; // variable type will default to string | null
let objectValue = { // declare an anonymous type { stringValue: string | null, length: number | undefined }
stringValue: maybeString, // by default the type of this field will be the type of the variable
length: maybeString?.length // we can use the ? operator. to return undefined if maybeString does not have a value, the field type will be number | undefined
}
let array: number[] = [1, 2, 3]; // implicitly this vector will contain values of the type of values declared here, if the type is not explicitly declared then this vector would be a tuple [number, number, number]
function isOdd(value: number) { // declare a function of type (value: number) => boolean
return number % 2 === 0;
}
const isOddVariable: (value: number) => boolean = isOdd;
const isEven = (value: number): boolean => { return number % 2 === 0; }; // assign a lambda of type (value: number) => boolean
let isOddResult = isOddVariable(numericValue);
let isEvenResult = isEven(numericValue); // we can call a lambda like any normal function
Declaring new types
We have seen how we can define object types anonymously but for better code organization we can declare complex types using the type and interface keywords like this:
type Point2D = { // declare an alias for this object type
x: number
y: number
test: () => boolean
}
interface Point2D { // declare an interface of this form
x: number
y: number
test: () => boolean
}
In principle, both type aliases and interfaces can be used to declare the "shape" of an object, but there are some differences here. Type aliases against interfaces can be used for types other than objects such as unions.
type ValueStringUnion = "value1" | "value2" | "value3"; // this type represents strings with only these three possibilities
type StringOrStringGet = string | () => string; // declare a type that can be a string or a function that returns a string
type StringAndStringSet = [string, (value: string) => void]; // declare a tuple type that contains a string and a function that uses a string
Both type aliases and interfaces can be extended.
interface Point2D { x: number; y: number; }
interface Point3D extends Point2D { z: number; } // an interface can extend another interface
type Point2D = { x: number; y: number; }
type Point3D = Point2D & { z: number; } // same for type alias
interface Point2D { x: number; y: number; }
type Point3D = Point2D & { z: number; } // a type alias can also extend an interface
type Point2D = { x: number; y: number; }
interface Point3D extends Point2D { z: number; } // likewise interface can extend an alias if it's not a union
Interfaces versus type aliases can be merged like for example:
interface Point2D { x: number; }
interface Point2D { y: number; }
const point: Point2D = { x: 0, y: 0 }; // no error, final interface will be combined from both declarations
We should mention here that regardless of whether we are talking about type alias or interface, types will be checked and must respect type constraints when used, for example, in a function or when assigning variables. For example, if a function has a parameter of type string | number can then receive as a parameter a variable of this type or of type string or nubmer because both are subtypes of this union. If instead the type is { name: string } & { value: number } then necessarily the received value must be of the form { name: string, value: number } or something that extends this type.
Classes
As in object-oriented languages in JavaScript/TypeScript there is also support for declaring classes but in general they are not so frequently used. We will only mention how they can be declared and used:
class PointImplementation implements Point2D { // can implement both an interface and a type alias
private name: string;
public x = 0;
public y = 0;
public constructor(name: string) {
this.name = name;
}
public getName(): string {
return this.name;
}
}
const point = new PointImplementation("NewPoint");
Enumerations
In TypeScript we can declare classic enumerations in addition to unions like this:
enum Direction {
Left, // we can assign values like Left = 1
Right
}
const directionLeft = Direction.Left; // we can address the value from the enum in the classic way
const directionRight = Direction["Right"]; // we can address value from enum like in a dictionary
const numbericValue: number = Direction.Left; // default enum values are number
By default the values of the enumerations are numbers but we can also assign strings:
enum Direction {
Left = "Left",
Right = "Right"
}
Operations on objects
In JavaScript/TypeScript objects can be used in a very dynamic way. These can be considered key-value dictionaries after all, even vectors are objects. For this reason we can create objects in place by assigning values field by field but it can become problematic for large objects. To simplify the work with objects, the operator ... (spread operator) can be used, with it we can construct and deconstruct objects.
const oldObject = { name: "test", value: 10 };
const otherObject = { ...oldObject, otherName: "otherTest" }; // will be { name: "test", value: 10, otherName: "otherTest" }, the "..." operator spills the fields from the old object into the new
const newObject = { ...oldObject, name: "newTest" }; // will be { name: "newTest", value: 10 }, the values cast in the new object can be overwritten with another value
const overrideObject = { name: "newTest", newValue: 15, ...oldObject }; // will be { name: "test", value: 10, newValue: 15 }, overriding values depends on the order declared
const { name } = oldObject; // we can deconstruct the object and extract a value into given variable, name = "test"
const { name: otherName, ...rest } = oldObject // we can extract the rest of the object into another variable using "...", otherName = "test", rest = { value: 10 }
overrideObject.name = "newName"; // we can address fields in objects as fields in a structure
overrideObject["name"] = "newName"; // or we can address a field from the object as in a dictionary
const oldArray: string[] = ["a", "b", "c"];
const firstArray = [...oldArray, "d"]; // will be ["a", "b", "c", "d"], we can use the "..." operator on vectors as well
const secondArray = ["d", ...oldArray]; // will be ["d", "a", "b", "c"], likewise, it depends on the order of declaration how the new vector is created
const [a, b] = oldArray; // we can extract values from the vector by deconstruction, a = "a", b = "b"
const [value, ...otherRest] = oldArray; // we can also extract by deconstruction the rest of the vector into another vector, value = "a", otherRest = ["b", "c"]
oldArray[0] = "e"; // as in other languages indexed addressing can be used for values in vectors
Genericity
In many situations genericity is needed to reduce duplicate code. In general, genericity in TypeScript is used in the same way as in other languages with support for genericity.
type GenericType<T> = { // we can declare both interfaces and type aliases generically
value: T;
}
function logValue<T extends { name: string }>(value: T) { // we can also put type constraints for the generic parameter, here it must be an object with a "name" field of string type
const { name, ...rest } = value;
console.log("Name is: ", name);
console.log("Rest is: ", JSON.stringify(rest));
}
const logValue = <T extends { name: string }>(value: T) { // we can also declare generic lambda functions
const { name, ...rest } = value;
console.log("Name is: ", name);
console.log("Rest is: ", JSON.stringify(rest));
}
Equality
A common cause of errors is that in JavaScript/TypeScript the equality operator == does not take into account the type of the compared values. For this reason the operator === must be used to avoid various problems in the code. The same applies to != and !==.
"" == false // true
"" === false // false
0 == false // true
0 === false // false
"0" == 0 // true
"0" === 0 // false
"0" == "" // false
"0" === "" // false
[] == false // true
[] === false // false
[] == 0 // true
[] === 0 // false
[] == "" // true
[] === "" // false
null == 0 // false
null === 0 // false
null == false // false
null === false // false
null == "" // false
null === "" // false
null == [] // false
null === [] // false
undefined == 0 // false
undefined === 0 // false
undefined == false // false
undefined === false // false
undefined == "" // false
undefined === "" // false
undefined == [] // false
undefined === [] // false
null == undefined // true
null === undefined // false
Modules
When working with JavaScript/TypeScript we can split the code into multiple .js/.ts source files to represent different modules. Modules work like singleton objects, they are initialized only once. In a module we can declare types, variables, constants and functions and export them to be visible in other modules:
const logValue = (value: string) => console.log(value); // we can declare variables and export them
export { logValue }; // here we export a previously declared value
export const logValue = (value: string) => console.log(value); // we can directly export a value
const logValue = (value: string) => console.log(value);
export default logValue; // we can export as a default value a value from the module
Symbols can be imported into other modules like this:
import { logValue } from "./logging" // files can be imported relative to the path where the current module is located, non-default symbols are imported here, the imported file is logging.ts
import logValue from "./logging" // here we import a symbol that is exported by default
There must be no cyclic dependencies between modules. For example, if module A' imports symbols from module
B' and vice versa, when loading the modules certain symbols will be undefined depending on which module is resolved first. Avoid at all costs having cyclical dependencies between modules.
Asynchronicity
When working with HTTP requests to a server we will have threads running behind the browser application to process the I/O requests. For this reason in JavaScript/Typescript the keywords async and await are supported to work with asynchronous functions more easily. When we need to request data from the server an HTTP request will be issued that returns a Promise object that can be expected to complete using await but only in an asynchronous function marked as ** async**.
import { someAsyncFunction } from "AsyncFunction";
const syncCall = (): Promise<string | void> => {
return someAsyncFunction() // the synchronous function returns a Promise
.then(e => e.json()) // we can do processing on the Promise data once it appears
.catch(error => console.error(error)) // on error we can catch the error
.finally(() => console.log("Request finished!")); // and we can execute a final statement, a Promise is also returned
}
const asyncCall = async (): Promise<string | void> => { // here we declare the function as asynchronous
try { // in an async function we can wait for a Promise with data
const result = await someAsyncFunction();
return result.json();
} catch (error) {
console.error(error);
} finally {
console.log("Request finished!");
}
}