These are notes from Udemy course Typescript: The Complete Developer's Guide 2020 by Stephen Grider.
- Typescript Notes
const today = new Date();
today.getMonth();
const person = {
age: 20
};
class Color {}
const red = new Color();
These are all type annotations but none are needed...
let apples: number = 5;
let speed: string = 'fast';
let hasName: boolean = true;
let nothingMuch: null = null;
let nothing: undefined = undefined;
// built in objects
let now: Date = new Date();
// Array
let colors: string[] = ['red', 'green', 'blue'];
let myNumbers: number[] = [1, 2, 3];
let truths: boolean[] = [true, true, false];
// Classes
class Car {}
var myCar: Car = new Car();
// Object literal
let point: { x: number; y: number } = {
x: 10,
y: 20
};
// Function
// - this does not annotate the function...
// - it annotates the variable that the function is assigned to
const logNumber: (i: number) => void = (i: number) => {
console.log(i);
};
If declaration and initialization are on the same line, TypeScript will figure out the type for us.
let oranges = 5;
let distance = 'far';
let hasAge = true;
Type annotations should be used in 3 cases:
- When a functions returns the
any
type and we need to clarify the value. - When we declare a variable on one line then initialize it later.
- When we want a variable to have a type that can't be inferred.
any
type is:
- a type, just as
string
orboolean
are. - means TS has no idea what this is.
- TS can't check for correct property references.
AVOID VARIABLES WITH any
AT ALL COSTS!
Without annotation:
const json = '{"x": 10, "y": 20}';
const coordinates = JSON.parse(json);
console.log(coordinates); // {x: 10, y: 20 };
Fix:
const json = '{"x": 10, "y": 20}';
const coordinates: { x: number; y: number } = JSON.parse(json);
console.log(coordinates); // {x: 10, y: 20 };
Without annotation:
let words = ['red', 'green', 'blue'];
let foundWord;
for (let word of words) {
if (word === 'green') {
foundWord = true;
}
}
Fix:
let words = ['red', 'green', 'blue'];
let foundWord: boolean;
for (let word of words) {
if (word === 'green') {
foundWord = true;
}
}
Without annotation:
let numbers = [-10, -1, 12];
let numberAboveZero = false;
for (let number of numbers) {
if (number > 0) {
numberAboveZero = number;
}
}
Fix:
let numbers = [-10, -1, 12];
let numberAboveZero: boolean | number = false;
for (let number of numbers) {
if (number > 0) {
numberAboveZero = number;
}
}
Code we add to tell TypeScript:
- what type of arguments a function will receive
- what type of values it will return
- TypeScript tries to figure out what type of value a function will return
- Only works for return type of a function not its arguments
Arrow function without annotation:
const add1 = (a, b) => {
return a + b;
};
Fix:
const add = (a: number, b: number): number => {
return a + b;
};
We ALWAYS have to use type annotation for arguments (a and b).
Type inference dictates that we don't need to specify return type but we ALWAYS WILL!
This is why...
forgetting to return the result means TS infers a void
return type and we lose TypeScript's ability to catch the syntax error.
// named function
const subtract = (a: number, b: number) => {
a - b;
};
// anonymous function
const multiply = function(a: number, b: number): number {
return a * b;
};
// can return 'null' or 'undefined' with 'void' return type
const logger = (message: string): void => {
console.log(message);
};
// rare corner case for function that will never return
const throwError = (message: string): never => {
throw new Error(message);
};
// if it may possibly return a 'string' then we annotate it as such
const throwError2 = (message: string): string => {
if (!message) {
throw new Error(message);
}
return message;
};
// if there's a chance it may return 'void' then we annotate it as such
const throwError3 = (message: string): void => {
if (!message) {
throw new Error(message);
}
};
// define weather object...
const todaysWeather = {
date: new Date(),
weather: 'sunny'
};
// annotation of forecast argument which is a weather object...
const logWeather = (forecast: { date: Date; weather: string }): void => {
console.log(forecast.date);
console.log(forecast.weather);
};
logWeather(todaysWeather);
// ES2015 destructuring (without annotations)
const logWeather2 = ({ date, weather }) => {
console.log(date);
console.log(weather);
};
logWeather2(todaysWeather);
Destructuring with annotation
- replace variable itself with the actual destructuring statement
- notice that destructuring and annotations are ALWAYS separate.
- We did not try to annotate the destructured properties.
- The destructuring and annotations are always going to be separated by colon (:)
const logWeather3 = ({
date,
weather
}: {
date: Date;
weather: string;
}): void => {
console.log(date);
console.log(weather);
};
Here is an object literal.
const profile = {
firstName: 'alex',
age: 20,
coords: {
lat: 0,
lng: 15
},
setAge(age: number): void {
this.age = age;
}
};
Notice that rather than setting a function equal to a property, we are using ES2015 syntax of defining a method inside an object.
Now we can reference a property.
// ES5
const age = profile.age;
// ES2015 object destructuring - no annotation
const { age } = profile;
If we need to annotate because of one of the three reasons then we need to annotate the structure of the whole property and not just the value...
// wrong!
const { age }: number = profile;
// right
const { age }: { age: number } = profile;
Destructuring multiple properties:
const { age, firstName }: { age: number; firstName: string } = profile;
ES2015 object destructuring - no annotation:
const { coords: { lat, lng } } = profile;
ES2015 object destructuring with annotation:
const {
coords: { lat, lng }
}: {
coords: { lat: number; lng: number }
} = profile;
Generally arrays in TS use only one data type per array...
const carMakers = ['ford', 'toyota', 'chevy'];
Type inference shows carMakers
is type string[]
because we assigned values at instantiation... no need to annotate.
We would want to annotate if we were not initializing the array.
const carModels: string[] = [];
We can also put complex objects inside of arrays
const dates = [new Date(), new Date()];
No annotation needed when array is initialized
const carsByMake = [['f150'], ['corolla'], ['camaro']];
Annotation included since we are not initializing...
const carByMake2: string[][] = [];
- TS can do type inference when extracting values from an array
- TS can prevent us from adding incompatible values to the array
- We can get help with 'map', 'forEach', 'reduce' functions
- Flexible - arrays can still contain multiple different types
// 1) Help with inference when extracting values
const car = carMakers[0];
const myCar = carMakers.pop();
// 2) Prevent incompatible values
carMakers.push(100);
// 3) Help with 'map'
carMakers.map((car: string): string => {
return car.toUpperCase(); // autocomplete on methods....
});
// 4) Flexible types
const importantDates = [new Date(), '2019-10-01'];
const importantDates2: (Date | string)[] = [new Date()];
importantDates2.push('2020-01-01'); // accepts string
importantDates2.push(new Date()); // accepts date
importantDates2.push(100); // doesn't accept
Where to use types arrays?
Any time we need to represent a collection of records with some arbitrary sort order.
Tuple - Array-like structure where each element represents some property of a record.
Whereas arrays can hold many different records or a collection of records...a tuple usually contains multiple different properties to describe one single thing.
Usually inside of a tuple we will mix and match many different types of data.
A tuple is an array where we put the values in a very specific order... It loses its labels (as in an object) but keeps the values.
Here's an object representation of a drink.
const drink = {
color: 'brown',
carbonated: true,
sugar: 40
};
Without annotation TS infers it as an array with swappable values.
const pepsi = ['brown', true, 40];
// Wrong inference: pepsi: (string | boolean | number)[]
Annotation turns this into a tuple rather than array.
// Tuple
const pepsi: [string, boolean, number] = ['brown', true, 40];
To simplify we can also use a type alias. This eliminates having to annotate for each variable declaration.
// Type alias defining a tuple
type Drink = [string, boolean, number];
This creates a brand new type that we can use as a tuple. Now we can create many different drinks.
// Tuple instances
const cola: Drink = ['brown', true, 40];
const sprite: Drink = ['clear', true, 40];
const tea: Drink = ['brown', false, 0];
Why use them? Perhaps when processing a CSV file to represent a single row...
// Here's a tuple
const carSpecs: [number, number] = [400, 3354];
// But objects may be better suited bc the key describes the data...
const carStats = {
horsepower: 400,
weight: 3354
};
Interfaces + Classes = How we get really strong code reuse in TS.
Interfaces - Creates a new type, describing the property names and value types of an object.
Here's an object literal that we will pass to a function.
// Object literal
const oldCivic = {
name: 'civic',
year: 2000,
broken: true
};
Annotating function arguments that are objects are unwieldy.
// Unwieldy and hard to read
const printVehicle = (vehicle: {
name: string;
year: number;
broken: boolean;
}): void => {
console.log(`Name: ${vehicle.name}`);
console.log(`Year: ${vehicle.year}`);
console.log(`Broken?: ${vehicle.broken}`);
};
printVehicle(oldCivic);
This is not ideal bc we would have to annotate for each new function and it is unwieldy to work with if any more properties are added.
- Interfaces are created above the functions that use it.
- They begin with a capital letter.
- They use a generic term (Vehicle not Civic).
interface Vehicle {
name: string;
year: number;
broken: boolean;
}
// Replace the annotation with a reference to the interface
const printVehicle = (vehicle: Vehicle): void => {
console.log(`Name: ${vehicle.name}`);
console.log(`Year: ${vehicle.year}`);
console.log(`Broken?: ${vehicle.broken}`);
};
printVehicle(oldCivic);
Here's a more complex object where we use Date
for year
and a function for summary
.
// complex object
const oldDatsun = {
name: 'datsun',
year: new Date(),
broken: true,
summary(): string {
return `Name: ${this.name}`;
}
};
We can use complex data types such as Date
for year
and can express a function for summary
inside of our interface.
interface Vehicle {
name: string;
year: Date;
broken: boolean;
summary(): string; // function that returns a string
}
const printVehicle = (vehicle: Vehicle): void => {
console.group('Vehicle3 Interface');
console.log(`Name: ${vehicle.name}`);
console.log(`Year: ${vehicle.year}`);
console.log(`Broken?: ${vehicle.broken}`);
console.log(vehicle.summary());
console.groupEnd();
};
printVehicle(oldDatsun);
Here's an example that only defines a single property for an interface.
interface Reportable {
summary(): string;
}
const printSummary = (item: Reportable): void => {
console.group('Reportable Interface');
console.log(item.summary());
console.groupEnd();
};
printSummary(oldDatsun);
Since printSummary
is only accessing the summary()
property we only need to define that property in the interface.
We also further abstract the interface name to Reportable
, saying, "In order to be considered type Reportable
, you must have a summary
function that returns a string".
Here is another object that also has a summary()
property.
const drink = {
color: 'brown',
carbonated: true,
sugar: 40,
summary(): string {
return `My drink has ${this.sugar} grams of sugar`;
}
};
Since drink
has a summary
function that returns a string just like oldDatsun
has a summary
function that returns a string... both objects are considered to be of Reportable
types.
That means we can call printSummary
on each of theses very different objects.
printSummary(oldDatsun);
printSummary(drink2);
We can use an interface to describe the various properties (or shape) of very different objects.
This encourages us to write somewhat generic looking functions and interfaces. For example, function name of printSummary
rather than printVehicleSummary
or interface name of Reportable
rather than Vehicle
Interface Reportable
is a gatekeeper to printSummary
...
oldDatsun
and drink
must satisfy Reportable
interface to work with printSummary
.
This mechanic of using an interface for gatekeeping is going to be one of the prime ways we get code reuse from TS.
- Create functions that accept arguments that are typed with interfaces
- Objects/classes can decide to 'implement' a given interface to work with a function
someFunction()
implements interfaceXYZ
.XYZ
is a gatekeeper tosomeFunction()
.object1
andobject2
must satisfy theXYZ
interface to work withsomFunction()
.
Rest of the course will focus on defining different interfaces to restrict access to different functions... and then decide how to implement those different interfaces in different objects that we're going to create!
- The goal of an interface is to define a new type.
- TS iterates thru all the properties of an interface to make sure the object has the same properties with the same type.
Classes are a blueprint to create an object with some fields (values) and methods (functions) to represent a 'thing'.
In TS we can use modifiers...
The goal of modifiers is to restrict access to different functions or different variables
public
- This method can be called any where, any timeprivate
- This method can only be called by other methods in this classprotected
- This method can be called by other methods in this class, or by other methods in child classes
When we create classes we can add modifiers
- Step 1 - Fields (values)
- Step 2 - Methods (functions)
Fields - either we initialize a property on the same line...
class Vehicle {
color: string = 'red'; // initialize property on same line
}
or we initialize in the constructor...
class Vehicle {
color: string;
constructor(color: string) {
this.color = color; // initialize property in constructor
}
}
Here's a shorthand for creating public fields that can be initialized in the constructor.
class Vehicle {
constructor(public color: string) {}
}
Next we can add methods.
class Vehicle {
constructor(public color: string) {}
protected honk(): void {
console.log('beep');
}
}
We don't actually call methods on the class directly. Usually we create an instance with the new
keyword and call methods off of that object.
const vehicle = new Vehicle('orange');
console.log(vehicle.color);
vehicle.honk();
Next we create a Car
class.
class Car extends Vehicle {
constructor(public wheels: number, color: string) {
super(color);
}
private drive(): void {
console.log('screech');
}
startDrivingProcess(): void {
this.drive();
this.honk();
}
}
const car = new Car(4, 'red');
car.startDrivingProcess(); // screech beep
We do not use public
keyword for color
bc we are not creating a new color
field for Car
. We are referencing color
in Vehicle
.
Type guards allow methods on a type to become available.
Without type guards, only the methods in common for the union would be usable.
- Primitive types (number, string, boolean, symbol) uses
typeof
- Every other type uses
instanceof
class Sorter {
constructor(public collection: number[] | string) {}
sort(): void {
const { length } = this.collection;
for (let i = 0; i < length; i++) {
for (let j = 0; j < length - i - 1; j++) {
// All of this only work if collection is number[]
// If collection is an array of numbers
if (this.collection instanceof Array) { // type guard
if (this.collection[j] > this.collection[j + 1]) {
const leftHand = this.collection[j];
this.collection[j] = this.collection[j + 1];
this.collection[j + 1] = leftHand;
}
}
// Only going to work if collection is a string
// If collection is a string
if (typeof this.collection === 'string') { //type guard
}
}
}
}
}
const sorter = new Sorter([10, 3, -5, 0, -10, 2]);
sorter.sort();
console.log(sorter.collection);
Abstract classes cannot be instantiated directly but serve as a model from which to base other classes from.
They allow you to specify abstract properties and methods that must exist in the child class.
export abstract class Sorter {
abstract length: number;
abstract compare(leftIndex: number, rightIndex: number): boolean;
abstract swap(leftIndex: number, rightIndex: number): void;
sort(): void {
const { length } = this; // destructure
for (let i = 0; i < length; i++) {
for (let j = 0; j < length - i - 1; j++) {
if (this.compare(j, j + 1)) {
this.swap(j, j + 1);
}
}
}
}
}
length
, compare
, and swap
must all be implemented in child classes.
The NumbersCollection
classes inherits from Sorter
.
export class NumbersCollection extends Sorter {
constructor(public data: number[]) {
super();
}
// adding 'get' makes length become a property
get length(): number {
return this.data.length;
}
compare(leftIndex: number, rightIndex: number): boolean {
return this.data[leftIndex] > this.data[rightIndex];
}
swap(leftIndex: number, rightIndex: number): void {
const leftData = this.data[leftIndex];
this.data[leftIndex] = this.data[rightIndex];
this.data[rightIndex] = leftData;
}
}
The CharactersCollection
also inherits from Sorter
.
export class CharactersCollection extends Sorter {
constructor(public data: string) {
super();
}
get length(): number {
return this.data.length;
}
compare(leftIndex: number, rightIndex: number): boolean {
return (
this.data[leftIndex].toLowerCase() > this.data[rightIndex].toLowerCase()
);
}
swap(leftIndex: number, rightIndex: number): void {
const characters = this.data.split('');
const leftData = characters[leftIndex];
characters[leftIndex] = characters[rightIndex];
characters[rightIndex] = leftData;
this.data = characters.join('');
}
}
Enum is an object that stores some closely related values.
Use enum to signal to other developers that these are a set of closely related objects.
enum MatchResults {
HomeWin = 'H',
AwayWin = 'A',
Draw = 'D'
}
Use whenever we have a small fixed set of values that are all closely related and known at compile time.
- Like function arguments, but for types in class and function definitions
- Allows us to define the type of a property/argument/return value at a future point in time
- Used heavily when writing reusable code
Instead of creating hard-coded addOne
and addTwo
functions...
// Hard-code add
const addOne = (a: number): number => {
return a + 1;
};
const addTwo = (a: number): number => {
return a + 2;
};
We can create a dynamic add
function by passing in a second argument.
// Dynamic add
const add = (a: number, b: number): number => {
return a + b;
};
add(10, 1);
add(10, 2);
add(10, 3);
In the same way we can create a function argument to create a dynamic add
function, we can create dynamic type arguments to a function/class definition. This allows us define the type at a later point.
Here is a class example...
class HoldNumber {
data: number;
}
class HoldString {
data: string;
}
const holdNumber = new HoldNumber();
holdNumber.data = 123;
const holdString = new HoldString();
holdString.data = 'abc';
This is a silly way of structuring code. We would never create duplicate class definitions and just sub out one property...
So rather than defining separate classes we can just use a Generic.
- A generic is going to customize the definition of this class in the exact same way that the
b
argument in the previous function customized the body of the function. - Instead we write a single class that does what both the previous classes were doing but it allows us to customize the types on the fly.
class HoldAnything<TypeOfData> {
data: TypeOfData;
}
Notice we supply a type as an argument to the class in the angle brackets.
Now when we create an instance of HoldAnything
, we can pass in an argument for TypeOfData
.
Remember, when we call a function we have to pass in a value for each argument... In the same way, when we define a generic, we have to pass in a type for the generic value TypeOfData
.
const holdNumber = new HoldAnything<number>();
holdNumber.data = 123;
const holdString = new HoldAnything<string>();
holdString.data = 'abc';
const holdDate = new HoldAnything<Date>();
holdDate.data = new Date('2019/12/28');
When we work with generics, view it exactly like we are working with a function argument.
By convention, rather than giving a generic a long name such as <TypeOfData>
we use a single letter like <T>
which refers to a generic type:
class HoldAnything<T> {
data: T;
add(a: T): T {
return a;
}
}
Here is a full example that uses an abstract class and generics.
export abstract class CsvFileReader<T> {
data: T[] = [];
constructor(public filename: string) {}
abstract mapRow(row: string[]): T;
read(): void {
this.data = fs
.readFileSync(this.filename, {
encoding: 'utf-8'
})
.split('\n')
.map((row: string): string[] => row.split(','))
.map(this.mapRow);
}
}
Inheritance uses abstract methods and properties inside of abstract classes to specify what methods and properties must be created in child classes.
This allows us to specify some core functionality that is then inherited by all child classes.
Composition allows us to specify a property that is defined by an interface which then all objects being assigned to that property must conform to.
- Inheritance - Characterized by an is a relationship between two classes.
- Composition - Characterized by a has a relationship between two classes.
In composition we can give an object a reference to another object in order to create the most amount of flexibility. The main object can then delegate behavior to the second object.
// Summary.ts
export type MatchDataTuple = [
Date,
string,
number,
];
export interface Analyzer {
run(matches: MatchDataTuple[]): string;
}
export interface OutputTarget {
print(report: string): void;
}
export class Summary {
constructor(public analyzer: Analyzer, public outputTarget: OutputTarget) {}
buildAndPrintReport(matches: MatchDataTuple[]): void {
const output = this.analyzer.run(matches);
this.outputTarget.print(output);
}
}
// Usage will be like...
// new Summary(new WinsAnalysis(), new ConsoleReport());
// WinsAnalysis.ts
import { Analyzer } from '../Summary';
import { MatchDataTuple } from '../Summary';
import { MatchResult } from '../MatchResult';
export class WinsAnalysis implements Analyzer {
constructor(public team: string) {}
run(matches: MatchDataTuple[]): string {
let wins = 0;
for (const match of matches) {
if (match[1] === this.team && match[5] === MatchResult.HomeWin) {
wins++;
} else if (match[2] === this.team && match[5] === MatchResult.AwayWin) {
wins++;
}
}
return `Team ${this.team} has won ${wins} games`;
}
}
// ConsoleReport.ts
import { OutputTarget } from '../Summary';
export class ConsoleReport implements OutputTarget {
print(report: string): void {
console.log(report);
}
}
// index.ts
import { MatchReader } from './MatchReader';
import { CsvFileReader } from './CsvFileReader';
import { WinsAnalysis } from './analyzers/WinsAnalysis';
import { ConsoleReport } from './reportTargets/ConsoleReport';
import { Summary } from './Summary';
// 1. Create an object that satisfies the 'DataReader' interface
const csvFileReader = new CsvFileReader('football.csv');
// 2. Create an instance of MatchReader and pass in something
// satisfying the 'DataReader' interface
const matchReader = new MatchReader(csvFileReader);
matchReader.load();
const summary = new Summary(
new WinsAnalysis('Man United'),
new ConsoleReport()
);
summary.buildAndPrintReport(matchReader.matches);
// Hard coded classes
class ArrayOfNumbers {
constructor(public collection: number[]) {}
get(index: number): number {
return this.collection[index];
}
}
class ArrayOfStrings {
constructor(public collection: string[]) {}
get(index: number): string {
return this.collection[index];
}
}
Instead of creating separate classes we can create a single class with a generic.
// Dynamic class
class ArrayOfAnything<T> {
constructor(public collection: T[]) {}
get(index: number): T {
return this.collection[index];
}
}
// type inference around generics shows...
// arr: ArrayOfAnything<string>
const arr = new ArrayOfAnything(['a', 'b', 'c']);
// explicit type definition
const arr = new ArrayOfAnything<string>(['a', 'b', 'c']);
We still should make a point of explicitly setting type definitions in order to catch errors.
// hard-coded functions
function printString(arr: string[]): void {
for (let i = 0; i < arr.length; i++) {
console.log('arr[i]', arr[i]);
}
}
function printNumbers(arr: number[]): void {
for (let i = 0; i < arr.length; i++) {
console.log('arr[i]', arr[i]);
}
}
Instead we create one new function that can receive any type of array
function printAnything<T>(arr: T[]): void {
for (let i = 0; i < arr.length; i++) {
console.log('arr[i]', arr[i]);
}
}
// explicit type definition
printAnything<string>(['a', 'b', 'c']);
// not <string[]>... that is two-dimensional array
We could also leave off the generic type and rely on type inference
// type inference would resolve this as:
// printAnything<string>(arr: string[]): void
printAnything(['a', 'b', 'c']);
Instead, it is recommended to always use explicit type definitions in order to catch errors like this...
printAnything<string>([1, 2, 3]); // type number not assignable to type string
class Car {
print(): void {
console.log('I am a car');
}
}
class House {
print(): void {
console.log('I am a house')
}
}
// here we have an error until we add a constraint to type T
function printHousesOrCars<T>(arr: T[]): void {
for (let i = 0; i < arr.length; i++) {
arr[i].print(); // Error
}
}
The fix involves creating an interface that we can use to extend the generic with.
// Fix
interface Printable {
print(): void;
}
// now we use extends keyword
function printHousesOrCars<T extends Printable>(arr: T[]): void {
for (let i = 0; i < arr.length; i++) {
arr[i].print(); // No error
}
}
printHousesOrCars([1, 2, 3]); // error
printHousesOrCars<House>([new House(), new House()]); // Good
printHousesOrCars<Car>([new Car(), new Car()]); // Good
printHousesOrCars<House | Car>([new House(), new Car()]); // Good
Decorators are functions that can be used to modify/change different properties/methods inside a class as well as static methods and properties, accessors, or the class itself.
- Not the same as JavaScript Decorators
- Used inside/on classes only
- Experimental
The key to understanding them decorators is understanding the order in which decorators work.
// decorators.ts
function testDecorator(target: any, key: string): void {
console.log('Target:', target);
console.log('Key:', key)
}
class Boat {
color = 'red';
get formattedColor(): string {
return `This boat's color is ${this.color}`;
}
@testDecorator
pilot(): void {
console.log('swish');
}
}
$ ts-node decorators.ts
Target: Boat { formattedColor: [Getter], pilot: [Function] }
Key: pilot
Here's the breakdown of arguments:
- First arg - is the prototype of the object
- Second arg - is the key of the prop/method/accessor on the object
- Third arg - is the property descriptor (more on this below)
Decorators are applied when the code for the class is run... (not when an instance is created)
Once again, a decorator only gets executed one single time, when we define the class.
The third argument is a property descriptor which is:
- an object that has some configuration options around a property defined on an object
It is not a part of Typescript but actually is a part of ES5 JavaScript.
A property descriptor is essentially an object that is meant to configure a property on another object.
Flag | Description |
---|---|
writable | Whether or not this property can be changed |
enumerable | Whether or not this property get looped over by a 'for...in' |
value | current value |
configurable | Property definition can be changed and property can be deleted |
> // Chrome Console
> const car = { make: 'honda', year: 2000 }
> Object.getOwnPropertyDescriptor(car, 'make')
> -{
configurable: true
enumerable: true
value: "honda"
writable: true
}
> Object.defineProperty(car, 'make', { writable: false });
> -{make: "honda", year: 2000}
> car
> -{make: "honda", year: 2000}
> car.make = 'chevy'
> "chevy"
> car
> -{make: "honda", year: 2000}
Now we can update our Boat class to catch errors.
function logError(target: any, key: string, desc: PropertyDescriptor): void {
const method = desc.value;
desc.value = function () {
try {
method();
} catch (e) {
console.log('Oops, boat was sunk');
}
}
}
class Boat {
color = 'red';
get formattedColor(): string {
return `This boat's color is ${this.color}`;
}
@logError
pilot(): void {
throw new Error();
console.log('swish');
}
}
new Boat().pilot();
Console result.
$ ts-node decorators.ts
Oops, boat was sunk
A decorator factory is a normal function that takes an argument and returns a decorator function.
This allows us to pass an argument to the decorator and have the decorator function use that argument.
// decorator factory
function logError(errorMessage: string) {
return function (target: any, key: string, desc: PropertyDescriptor): void {
const method = desc.value;
desc.value = function () {
try {
method();
} catch (e) {
console.log(errorMessage);
}
}
}
}
class Boat {
color = 'red';
get formattedColor(): string {
return `This boat's color is ${this.color}`;
}
@logError("Oops, boat sunk in the ocean")
pilot(): void {
throw new Error();
console.log('swish');
}
}
new Boat().pilot();
Console result.
$ ts-node decorators.ts
Oops, boat was sunk in the ocean
This decorator factory pattern is used often in order to allow flexibility with decorators.
Properties are not exposed thru the target argument of the decorator because target refers to the prototype.
Prototypes contain the methods but not the properties.
// method/property decorator
function testDecorator(target: any, key: string) {
console.log('key', key);
console.log(target[key]);
console.log(target.color);
}
class Boat {
@testDecorator
color = 'red';
get formattedColor(): string {
return `This boat's color is ${this.color}`;
}
pilot(): void {
throw new Error();
console.log('swish');
}
}
Trying to get a property value of an instance will never work with decorators since decorators run when the class is interpreted.
This is a huge limitation of decorators. We can only use them to get methods (built on the prototype) or verify property names.
$ ts-node decorators.ts
color
undefined
undefined
Setting the decorator on an accessor works as well but also does not provide property values.
// accessor decorator
function testDecorator(target: any, key: string) {
console.log(key);
console.log(target[key]);
console.log(target.color);
}
class Boat {
color = 'red';
@testDecorator
get formattedColor(): string {
return `This boat's color is ${this.color}`;
}
pilot(): void {
throw new Error();
console.log('swish');
}
}
$ ts-node decorators.ts
formattedColor
This boat's color is undefined
undefined
Parameter decorators can be used on the arguments (parameters) of a function.
They take three arguments:
target
: anykey
: stringindex
: number
See parameterDecorator
function below.
function parameterDecorator(target: any, key: string, index: number): void {
console.log(key, index);
}
function testDecorator(target: any, key: string): void {
console.log(key);
}
class Boat {
@testDecorator
color = 'red';
@testDecorator
get formattedColor(): string {
return `This boat's color is ${this.color}`;
}
pilot(
@parameterDecorator speed: string,
@parameterDecorator generateWake: boolean
): void {
if (speed === 'fast') {
console.log('swish');
} else {
console.log('nothing');
}
}
}
$ ts-node decorators.ts
color
formattedColor
pilot 1
pilot 0
Lastly, we can create a decorator on a class. The function takes one argument- constructor
.
function classDecorator(constructor: Function): void {
console.log(constructor);
}
@classDecorator
class Boat {
@testDecorator
color = 'red';
}
$ ts-node decorators.ts
color
[Function: Boat]
Import 'reflect-metadata' automatically add a variable to global scope.
import 'reflect-metadata';
Reflect.[some method]
Metadata is like some secret information which doesn't show up anywhere except through the use of the reflect-metadata package.
import 'reflect-metadata';
const plane = {
color: 'red'
};
Reflect.defineMetadata('note', 'hi there', plane);
Reflect.defineMetadata('height', 20, plane);
Reflect.defineMetadata('colorNote', 'hi again', plane, 'color');
const note = Reflect.getMetadata('note', plane);
const height = Reflect.getMetadata('height', plane);
const colorNote = Reflect.getMetadata('colorNote', plane, 'color');
console.log(note);
console.log(height);
console.log(colorNote);
$ ts-node metadata.ts
hi there
20
hi again
Metadata allows us to attach information to an object as well as a property.
Here's an example of tying information to a method.
import 'reflect-metadata';
function markFunction(target: Plane, key: string): void {
Reflect.defineMetadata('secret', 123, target, key);
}
class Plane {
color = 'red';
@markFunction
fly(): void {
console.log('vrrrr');
}
}
const secret = Reflect.getMetadata('secret', Plane.prototype, 'fly');
console.log(secret);
$ ts-node metadata.ts
123
If we want to pass in a string then it is done like this.
import 'reflect-metadata';
// decorator factory
function markFunction(secretInfo: string) {
return function(target: Plane, key: string): void {
Reflect.defineMetadata('secret', secretInfo, target, key);
};
}
class Plane {
color = 'red';
@markFunction('passing metadata param')
fly(): void {
console.log('vrrrr');
}
}
const secret = Reflect.getMetadata('secret', Plane.prototype, 'fly');
console.log(secret);
$ ts-node metadata.ts
passing metadata param
If a getter or setter is used as a pass-through method then this
on the destination method will refer to the pass through object and not the destination object.
this
refers to whatever is to the left of the method call.
// Reminder on how 'this' works in javascript
const colors = {
color: 'red',
printColor(): void {
console.log(this.color);
}
};
// 'this' is what ever is to the left of the method call
colors.printColor(); // "red"
const { printColor } = colors;
printColor(); // TypeError: Cannot read property 'color' of undefined
The destination object must use an arrow function for its getters and setters.
// normal getter/setter shorthand will not work with pass-through
export class Attributes<T> {
constructor(private data: T) {}
// generic constraint; K can only ever be a key of T
get<K extends keyof T>(key: K): T[K] {
return this.data[key];
}
set(update: T): void {
Object.assign(this.data, update);
}
}
The fix uses an arrow function.
// put equal after get/set and an arrow before brackets
export class Attributes<T> {
constructor(private data: T) {}
// generic constraint; K can only ever be a key of T
get = <K extends keyof T>(key: K): T[K] => {
return this.data[key];
};
set = (update: T): void => {
Object.assign(this.data, update);
}
}
In fact, we should ALWAYS use arrow functions 100% of the time with getters and setters so that we never have an ambiguous this
.
This can be done to extend poorly typed defs.
import { Router, Request, Response } from 'express';
interface RequestWithBody extends Request {
body: { [key: string]: string | undefined };
}
router.post('/login', (req: RequestWithBody, res: Response) => {
const { email, password } = req.body;
let resBody = '<div>You must provide an email</div>';
if (email) {
resBody = `
<div>email: ${email.toLocaleUpperCase()}</div>
<div>password: ${password}</div>
`;
}
res.send(resBody);
});
This is necessary to avoid "Object is possibly undefined".
// Replace this
router.get('/', (req: Request, res: Response) => {
if (req.session.loggedIn) {
// TS marks as error bcz session may be undefined
}
});
// with type guard
router.get('/', (req: Request, res: Response) => {
if (req.session && req.session.loggedIn) {
// this now tests if req.session exists
}
});