TypeScript Fundamentals

This is a summary of https://frontendmasters.com/courses/typescript-v3/.

Table of Contents

Intro

The purpose of this course is:

By the end of this course, I want you to have a rock solid mental model, that will serve you well for years

TypeScript consists of 3 parts: Language, Language Server, and Compiler:

  • Language – The syntax, semantic, and rules we must follow when we write TypeScript
  • Language Server – An endpoint from which VSCode checks the syntax’s correctness
  • Compiler – We convert TypeScript to JavaScript with compiler called tsc

Why developers want types?

  • It allows you, as a code author, to leave more of your intent “on the page”
  • It has the potential to move some kinds of errors from runtime to compile time
  • It serves as the foundation for a great code authoring experience

Hello TypeScript

{ "compilerOptions": { "outDir": "dist", // where to put the TS files "target": "ES3" // which level of JS support to target }, "include": ["src"] // which files to compile }

A possible mnemonic to remember the important parts of tsconfig is COTI:

  • C stands for compilerOptions
  • O stands for outDir, which specifies the output directory
  • T stands for target, which specifies level of JS support to target (ES3, ES5, ES2015, ES2017, etc)
  • I stands for include

A good way to think of TS files:

  • .ts files contain both type information and code that runs
  • .js files contain only code that runs
  • .d.ts files contain only type information

Variables and Values

  • Inference is not so specific as to get in the way of common behavior. When we write let age = 31;, TypeScript assumes that age will always point to number and cannot be assigned to other types
  • Let’s say const literal = "literal";. TypeScript will infer that the type of literal is “literal” not a string. Why? You can’t change the value to anything else because of the const. That’s why the type is always “literal”. Always will be. Always has been. That’s a literal type
  • Think of any as “the normal way JS variables work”, in that you could assign any variables to a number, then later a function, then a string. Anything.
  • What is type annotation? let name: string = "Tatang". A token string that follows name: is a type annotation.

Objects, Arrays, and Tuples

Objects

In TypeScript, objects are collections of key-value pairs, where the key is always a string. You can define an object type using the curly braces {} syntax:

type Person = { name: string; age: number; occupation: string; }

You can then create an object of this type like this:

const person: Person = { name: "John", age: 30, occupation: "Developer" }

You can also define optional properties on an object type by adding a ? after the property name:

type Person = { name: string; age: number; occupation?: string; }

This allows you to create objects with or without the occupation property.

Arrays

Arrays in TypeScript are similar to arrays in JavaScript, but with added type safety. You can define an array type by adding [] after the type of the elements:

let list: number[] = [1, 2, 3];

Tuples

Tuples are also available in TypeScript. A tuple is an array with a fixed number of elements, where each element has a specific type. You can define a tuple type using the square brackets [] syntax:

let tuple: [string, number] = ["hello", 10];

This tuple has two elements, the first of type string and the second of type number.

Index Signature

An index signature allows an object to have any number of additional properties, whose keys are strings and whose values can be of any type. Here’s an example:

interface Dictionary { [key: string]: number; } const dict: Dictionary = { one: 1, two: 2, three: 3, }; console.log(dict["two"]); // Output: 2

In this example, we define an interface Dictionary with an index signature that allows any string key and enforces a number value. We then create an object dict that conforms to this interface and assign it some key-value pairs.

Structural vs. Nominal Types

  • Structural type is about “shape”. Two objects are compatible if their “shape” are the same
  • Nominal type is about “name”. Two objects are compatible if they derive from the same class despite the shape.

Union and Intersection Types

Union Types

Union Types described by the | (pipe) operator.

Either A or B but not both.

We need to “separate” potential possibilities for our value. It’s either Cat or Dog, right? We can do this with type guards:

interface Cat { name: string; meow: () => void; } interface Dog { name: string; bark: () => void; } function isCat(animal: Cat | Dog): animal is Cat { return "meow" in animal; } function isDog(animal: Cat | Dog): animal is Dog { return "bark" in animal; } function pet(animal: Cat | Dog) { if (isCat(animal)) { animal.meow(); } if (isDog(animal)) { animal.bark(); } }

Or with discriminated unions:

interface Cat { kind: "cat"; name: string; meow: () => void; } interface Dog { kind: "dog"; name: string; bark: () => void; } type Pet = Cat | Dog; function pet(animal: Pet) { switch (shape.kind) { case "cat": animal.meow(); break; case "dog": animal.bark(); break; } }

Read Demystifying TypeScript Discriminated Unions.

Intersection Types

An intersection type combines multiple types into one.

A and B merged.

Intersection Types described by the & (ampersand) operator:

function makeWeek(): Date & { end: Date } { const start = new Date() const end = new Date(start.valueOf() + ONE_WEEK) return { ...start, end } }

Type Aliases and Interfaces

TypeScript provides two mechanisms for defining types and giving them useful and meaningful names: type aliases and interfaces.

Type Aliases

A type alias can hold any type, as it’s literally an alias (name) for a type of some sort:

type Binggo = 777; type Dog = { name: string; bark: () => void; }; type Cat = { name: string; meow: () => void; } type Pet = Cat | Dog; type CatDog = Cat & Dog;

Extending behaviour with type aliases by using intersection (&):

type SpecialDate = Date & { getReason(): string }

Interfaces

It’s best used for “object type” such as:

interface Person { name: string; age: number; } const me: Person = { name: "Muhamad D. R", age: 31 };

Use extends to “inherits” interface from a base interface:

interface Animal { isAlive(): boolean } interface Mammal extends Animal { getFurOrHairColor(): string } interface Dog extends Mammal { getBreed(): string } function careForDog(dog: Dog) { dog.isAlive(); dog.getBreed(); dog.getFurOrHairColor(); }

implements can be used to state that a given class should produce instances that confirm to a given interface. It’s like a contract for an instance of class:

class LivingOrganism { isAlive() { return true } } interface AnimalLike { eat(food): void } interface CanBark { bark(): string } class Dog extends LivingOrganism implements AnimalLike, CanBark { bark() { return "woof" } eat(food) { consumeFood(food) } }

TypeScript interfaces are “open”, meaning that unlike in type aliases, you can have multiple declarations in the same scope:

interface AnimalLike { isAlive(): boolean } function feed(animal: AnimalLike) { animal.eat(); animal.isAlive(); } // SECOND DECLARATION OF THE SAME NAME interface AnimalLike { eat(food): void }

Choosing Which To Use

  1. If you need to define something other than an object type (e.g., use of the | union type operator), you must use a type alias
  2. If you need to define a type to use with the implements heritage term, it’s best to use an interface
  3. If you need to allow consumers of your types to augment them, you must use an interface

Recursion Type

Here’s an example:

type Tree = { value: number; left?: Tree; right?: Tree; }; const tree: Tree = { value: 1, left: { value: 2, left: { value: 4 }, right: { value: 5 } }, right: { value: 3, left: { value: 6 }, right: { value: 7 } } };

JSON Types

type JSONPrimitive = number | string | boolean | null; type JSONObject = { [key: string]: JSONValue }; type JSONArray = JSONValue[]; type JSONValue = JSONObject | JSONArray | JSONPrimitive;

Functions

We can make a type alias or an interface for function:

type SayHelloType = (name: string, greeting: string) => string; interface SayHelloInterface { (name: string, greeting: string): string } const sayHello: SayHelloType = (name, greeting) => { return `Hello, ${name}! ${greeting}`; }; const sayHelloWithConsole: SayHelloInterface = (name, greeting) => { const expression = `Hello, ${name}! ${greeting}`; console.log(expression); return expression; };

The return value of void is meant to be ignored.

Function overloading is a way to define multiple versions of the same function that can handle different types of inputs and outputs:

function add(a: number, b: number): number; function add(a: string, b: string): string; function add(a: number | string, b: number | string): number | string { return a + b; } console.log(add(1, 2)); // Output: 3 console.log(add('hello', 'world')); // Output: helloworld

this keyword can be defined and invoked like this:

function focusAndLogEvent(this: HTMLInputElement, event: Event): void { this.focus(); console.log(event); } const input = document.getElementsByTagName("input")[0]; focusAndLogEvent.call(input, new Event('click')); const input2 = document.getElementsByTagName("input")[1]; const boundFocusAndLogEvent = focusAndLogEvent.bind(input2); boundFocusAndLogEvent(new Event('change'));

It is well-advised to explicitly define return types.

Classes

Here’s how we define types to a class:

class Car { make: string; model: string; year: number; constructor(make: string, model: string, year: number) { this.make = make; this.model = model; this.year = year; } }

TypeScript provides three access modifier keywords, which can be used with class fields and methods, to describe who should be able to see and use them:

  • public – everyone can access
  • private – only the instance itself can access
  • protected – the instance itself and subclasses can access

Access modifier keywords are only validated at compile time, with no real privacy or security benefits at runtime. Don’t use it for secret-keeping or security.

JS private #fields can be written as follow:

class Car { public make: string public model: string #year: number constructor(make: string, model: string, year: number) { this.make = make this.model = model this.#year = year } } const c = new Car("Honda", "Accord", 2017) c.#year // Throw error because it's private

readonly keyword:

class Car { public make: string public model: string public readonly year: number constructor(make: string, model: string, year: number) { this.make = make this.model = model this.year = year } updateYear() { this.year++ // Cannot assign to 'year' because it is a read-only property. } }

To simplify our class, we can use param properties. From this:

class Car { make: string model: string year: number constructor(make: string, model: string, year: number) { this.make = make this.model = model this.year = year } }

To this:

class Car { constructor( public make: string, public model: string, public year: number ) {} }

Note the following order of what ends up in the class constructor:

  1. super()
  2. Param property initialization
  3. Other class field initialization
  4. Anything else that was in your constructor after super()

Top and Bottom Types

  • Types describe sets of allowed values
  • top type (symbol: ) is a type that describes any possible value allowed by the system: any and unknown
  • bottom type (symbol: ) is a type that describes no possible value allowed by the system: never
  • any can be assigned to any values. No type guards needed
  • unknown can be assigned to any values but you can’t use it unless with type guards
  • never can never be assigned to any values

Type Guards and Narrowing

Sometimes we can’t assure the data we consume from API. TypeScript won’t catch it at compile-time. It could have been null or undefined right?

Type guards can be thought of as part of the “glue” that connects compile-time type-checking with the execution of your program at runtime.

Let’s say we expect { name: string, address: string } but it’s possible that we can get null or undefined. Here’s the example:

type Person = { name: string; address: string; }; type Data = Person | null | undefined; function isPerson(valueToTest: any): valueToTest is Person { return ( valueToTest && typeof valueToTest === "object" && "name" in valueToTest && typeof valueToTest["name"] === "string" && "address" in valueToTest && typeof valueToTest["address"] === "string" ); } function handlePerson(data: Data): Person { if (data === null) { throw new Error('It is null!'); } else if (data === undefined) { throw new Error('It is undefined!'); } else if (isPerson(data)) { // This is how we handle Person return data; } else { throw new Error('Never!'); } }

Nullish Values

Null

null is you set it to empty. Intentionally empty value. It’s empty on purpose.

Undefined

undefined it’s empty because it has not been set. Unintentionally empty value.

Void

void should exclusively be used to describe that a function’s return value should be ignored.

Non-null Assertion Operator

!. operator tells the compiler not to complain about the possiblity that user might access properties of null or underfined value. It is the opposite of ?. operator.

Example:

const fruits = null; try { fruits!.push('mango'); // TypeScript won't complain } catch (error) { throw new Error(error); }

Definite Assignment Operator

The definite assignment !: operator is used to suppress TypeScript’s objections about a class field being used, when it can’t be proven that it was initialized.

class ThingWithAsyncSetup { setupPromise: Promise<any> // Ignore the <any> for now isSetup!: boolean // TypeScript won't object this since we use `!:` constructor() { this.setupPromise = new Promise((resolve) => { this.isSetup = false // We initialize it here, but TypeScript doesn't know return this.doSetup(resolve) }).then(() => { this.isSetup = true }) } private async doSetup(resolve: (value: unknown) => void) { // some async stuff } }

Generics

It is a way to generalize types. What does that mean? Take that you want to convert array of something (T[]) into a dictionary of something ({[key: string]: T}) and T could be anything. Here’s the example:

type Dictionary<T> = { [key: string]: T }; function createDictionary<T>( array: T[] ): Dictionary<T> { // Impelmentation... return {}; } type Person = { name: string; age: number; } const numbers: number[] = [1, 2, 3]; const strings: string[] = ['hello', 'hello']; const people: Person[] = [{ name: 'Hiro', age: 21 }, { name: 'Tito', age: 40 }]; const dictionaryOfNumbers = createDictionary<number>(numbers); const dictionaryOfStrings = createDictionary<string>(strings); const dictionaryOfPeople = createDictionary<Person>(people);

T is called type parameter.

Dictionary (map, filter and reduce)

const fruits = { apple: { color: "red", mass: 100 }, grape: { color: "red", mass: 5 }, banana: { color: "yellow", mass: 183 }, lemon: { color: "yellow", mass: 80 }, pear: { color: "green", mass: 178 }, orange: { color: "orange", mass: 262 }, raspberry: { color: "red", mass: 4 }, cherry: { color: "red", mass: 5 }, } interface Dict<T> { [k: string]: T } // Array.prototype.map, but for Dict function mapDict<T, S>( dict: Dict<T>, callback: (item: T, name: string) => S ): Dict<S> { const newDict: Dict<S> = {}; Object.keys(dict).forEach(key => { newDict[key] = callback(dict[key], key); }); return newDict; } // Array.prototype.filter, but for Dict function filterDict<T>( dict: Dict<T>, callback: (item: T) => boolean ): Dict<T> { const filteredDict: Dict<T> = {}; Object.keys(dict).forEach(key => { if (callback(dict[key])) { filteredDict[key] = dict[key]; } }); return filteredDict; } // Array.prototype.reduce, but for Dict function reduceDict<T, S>( dict: Dict<T>, reducer: (accumulator: S, current: T) => S, initialValue: S ): S { let total: S = initialValue; Object.keys(dict).forEach((key) => { total = reducer(total, dict[key]); }); return total; }

Generics Scopes and Constraints

type Dictionary<T> = { [key: string]: T }; function createDictionary<T>( array: T[], idGenerator: (item: T) => string ): Dictionary<T> { const dict: Dictionary<T> = {}; array.forEach(item => { dict[idGenerator(item)] = item; }); return dict; }

As you can see T could be anything (number, string, boolean, etc). What if we take out idGenerator and decide that T is supposed be an object and has id property. The way we define constraints on T is by using the extends keyword.

Here’s how it’s done:

type Dictionary<T> = { [key: string]: T }; type HasId = { id: string }; function createDictionary<T extends HasId>( array: T[], ): Dictionary<T> { const dict: Dictionary<T> = {}; array.forEach(item => { dict[item.id] = item; }); return dict; }

Scopes and Type Params

When working with function parameters, we know that “inner scopes” have the ability to access “outer scopes” but not vice versa. Type params work a similar way:

// Outer function function tupleCreator<T>(first: T) { // Inner function return function finish<S>(last: S): [T, S] { return [first, last] } }