This is a summary of https://frontendmasters.com/courses/typescript-v3/.
Table of Contents
- Intro
- Hello TypeScript
- Variable and Values
- Objects, Arrays, and Tuples
- Structural vs. Nominal Types
- Union and Intersection Types
- Type Aliases and Interfaces
- JSON Types
- Functions
- Classes
- Top and Bottom Types
- Type Guards and Narrowing
- Nullish Values
- Generics
- Dictionary (
map
,filter
andreduce)
- Generics Scopes and Constraints
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 forcompilerOptions
O
stands foroutDir
, which specifies the output directoryT
stands fortarget
, which specifies level of JS support to target (ES3, ES5, ES2015, ES2017, etc)I
stands forinclude
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 thatage
will always point tonumber
and cannot be assigned to other types - Let’s say
const literal = "literal";
. TypeScript will infer that the type ofliteral
is “literal” not astring
. Why? You can’t change the value to anything else because of theconst
. 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 anumber
, then later afunction
, then astring
. Anything. - What is type annotation?
let name: string = "Tatang"
. A tokenstring
that followsname:
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
- 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 - If you need to define a type to use with the
implements
heritage term, it’s best to use an interface - 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 accessprivate
– only the instance itself can accessprotected
– 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:
super()
- Param property initialization
- Other class field initialization
- Anything else that was in your constructor after
super()
Top and Bottom Types
- Types describe sets of allowed values
- A top type (symbol:
⊤
) is a type that describes any possible value allowed by the system:any
andunknown
- A 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 neededunknown
can be assigned to any values but you can’t use it unless with type guardsnever
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]
}
}