guh.me - gustavo's personal blog

Understanding Typescript

My notes from the Udemy course: Understanding Typescript.

Understanding Typescript

The Typescript Compiler and its Configuration

tsc --init will initialize the tsconfig.json file. Most settings do not matter for a regular user.

lib lets you include built-in Typescript libraries. E.g. DOM.

module: "NodeNext" is a the most modern option for modules. allowJs: true lets you mix Javascript code into Typescript projects.

sourceMap: true is useful when debugging.

noEmitOnError: true will only output JS files if there are no errors.

tsc --watch will watch the project and trigger compilation on changes.

npm install @types/<package> provides types for popular libraries. The supported packages can be found at https://github.com/DefinitelyTyped/DefinitelyTyped.

Next-gen JavaScript and TypeScript

const is immutable. let is mutable. Unlike var, const and let and block scoped. Arrow functions can be stored in variables: const double = (a: number) => a * 2; The splat operator can be used to take “rest parameters”:

const add = (...numbers: number[]): number => {
    // return numbers.reduce()
};

The splat operator can also be used to destructure arrays and objects:

const [item1, item2, ...other] = [1,2,3,4,5];
coonst { prop1, prop2 } = { prop1: "foo", prop2: "bar"};

Classes

Classes in TS are not very different than in PHP.

class User {
    public readonly username: string;
    private _email: string = '';

    constructor(username: string, private readonly age: number) {
        this.username = username;
    }

    public get email(): string {
        return this._email;
    }

    public set email(newEmail: string) {
        this._email = newEmail;
    }

    public isMinor(): boolean {
        return this.age < 18;
    }

    static ENTITY = 'user';

    static getEntity(): string {
        return this.ENTITY;
    }
}

// promoted properties
const user = new User('bob', 25);
// setter
user.email = '[email protected]';     
// getter
console.log(user.email);         
// method using private property
console.log(user.isMinor());     
// static property
console.log(User.ENTITY);        
// static method
console.log(User.getEntity());   

Inheritance is as simple as using extends:

class SuperUser extends User {
    constructor(public role: string) {
        // parent constructor
        super('admin', 99);
    }
}

Contracts can be implemented using interface or type definitions. Using interface has the advantage that the interface itself can be extended.

interface Authenticatable {
    email: string;
    password: string;

    login(): void;
    logout(): void;
}

type Authenticatable {
    email: string;
    password: string;

    login(): void;
    logout(): void;
}

Type aliases have the advantage that you can also use them in type unions.

Type guards can be implemented using type predicates:

function isFish(pet: Fish | Bird): pet is Fish {
  return (pet as Fish).swim !== undefined;
}

Function overloading lets specify multiple signatures for the same implementation (they must match though):

class Foo {
    myMethod(a: string);
    myMethod(a: number);
    myMethod(a: number, b: string);
    myMethod(a: string | number, b?: string) {
        alert(a.toString());
    }
}

Dynamic object props can be declared with:

type Store = {
  [prop: string]: number,
};
let store: Store = {}; 
store.answer = 42;

It is like a hardcoded record.

as const can be used to mark a value as deeply readonly.

The satisfies operator lets us validate that the type of an expression matches some type, without changing the resulting type of that expression.

type Colors = "red" | "green" | "blue";
type RGB = [red: number, green: number, blue: number];
const palette = {
    red: [255, 0, 0],
    green: "#00ff00",
    blau: [0, 0, 255]
//  ~~~~ The typo is now caught!
} satisfies Record<Colors, string | RGB>;

Generics

You know what they are about. They are a superior option to any.

To define a new generic type:

type DataStore<T> = {
    [key: string]: T,
};

// using the generic type
let store: DataStore<string> = {};

Functions can also be generic:

function merge<T>(a: T, b: T): T[] {
    return [a, b];
}
merge(1, 2); // [1,2]

// also with different types
function merge2<T, U>(a: T, b: U) {
    return [a, b];
}
merge2(1, 'Bob'); // [1,'Bob']

You can also add constraints to your type declaration:

// The type used here must be any kind of object, not
// a primitive
function mergeObj<T extends object>(a: T, b: T) {
    return { ...a, ...b};
}

You can also specify multiple generic types with different constraints:

function mergeObj<T extends object, U extends object>(a: T, b: U) {
    return { ...a, ...b};
} 

Classes and interfaces can also be made generic:

class User<T> {
    constructor(public id: T) {}
}

interface Role<T> {
}

Deriving Types from Types

You can derive a type from an existing type using typeof:

type Username = string;

// Use same type for admins. This is **not** the default
// JS typeof operator, but the TF one.
type AdminUsername = typeof Username;

// You can also derive it from an existing structure
const settings = {
    difficulty: 'easy',
    level: 10,
    players: ['John', 'Jane'],
};

type Settings = typeof settings;
// Settings = { difficulty: string; level: number; players: string[] }

// You can also use it on functions
function sum(a: number, b: number) {
    return a + b;
}

type SumFn = typeof sum;

// And use it as an argument
function calculate(fn: SumFn) {
    //...
}

The keyof operator can be used to extract the type of

type User = { name: string; age: number };

// UserKeys is an union type of all keys from the object User.
type UserKeys = keyof User;

// T must be an object, and K is a key from this object
function getProp<T extends object, K extends keyof obj>(obj: T, key: K) {
    const val = obj[key];
    if (vall === undefined || vall === null) {
        throw new Error('invalid value');
    }
    
    return val;
}

You can use an indexed access type to look up a specific property on another type:

type AppUser = {
    name: string,
    permissions: {
        id: string,
        title: string;
    }[];
};

type Permissions = AppUser['permissions'];

// You can also get the type from the array values
type Permission = Permissions[number];

Sometimes a type needs to be based on another type. A mapped type is a generic type which uses a union of PropertyKeys (frequently created via a keyof) to iterate through keys to create a type:

type Operations = {
    request: (name: string) => void;
    cancel: (name: string) => void;
};

type OptionsFlags<T> = {
  [Property in keyof T]: boolean;

  // Alternate where props are optional
  [Property in keyof T]?: boolean;
  
  // Alternate where all props are mandatory, even if they
  // are originally optional optional
  [Property in keyof T]-?: boolean;

  // To make all props readonly
  readonly [Property in keyof T]: boolean

  // To make all props **not** readonly
  -readonly [Property in keyof T]: boolean
};

// E.g. now you can create options for each method of Operations,
// where new constraints will be automatically added.
const opts: OptionsFlags<Operations> = {
    request: ['strict', 'dry-run'],
    cancel: ['atomic'],
};

Template literal types

type ReadPermissions = 'read' | 'no-read';
type WritePermissions = 'write' | 'no-write';
// This is not a regular template, but a literal template type.
type FilePermissions = `${ReadPermissions}-${WritePermissions}`
// Creates an union type of:
// read-write | read-no-write | no-read-write | no-read-no-write

Conditional types help describe the relation between the types of inputs and outputs.

interface Animal {
  live(): void;
}
interface Dog extends Animal {
  woof(): void;
}
 
type Example1 = Dog extends Animal ? number : string;

// You can also use constraints
type MessageOf<T extends { message: unknown }> = T["message"];

interface Email {
  message: string;
}
 
type EmailMessageContents = MessageOf<Email>;

// Also as function return types
type FullNamedPerson = { firstName: string; lastName: string };
function getFullName<T extends object>(
    person: T
): T extends FullNamedPerson ? string : never {
    // check methods and return full name
    // return 'fullname';

    // or throw an error
    throw new Error('error');
}

To describe a function with any amount of parameters:

(...args: any[]) => void

How to infer a type:

type ReturnValueType<T> = T extends (...args: any[]) => infer Ret ? Ret : never;

function add(a: number, b: number) {
    return a + b;
}
type AddFn = typeof add;
type AddFnReturnValueType = ReturnValueType<AddFn>;

Built-in utility types:

// https://www.typescriptlang.org/docs/handbook/utility-types.html
Partial<Type>
Required<Type>
Readonly<Type>
Record<Keys, Type>
Pick<Type, Keys>
Omit<Type, Keys>
Exclude<UnionType, ExcludedMembers>
Parameters<Type>
ReturnType<Type>

Decorators

Decorators provide a way to add both annotations and a meta-programming syntax for class declarations and members. ECMA decorators and TS decorators are slightly different.

export class Post {
    @Length(10, 20)
    title: string;
}

Decorators can only be used in OOP contexts - classes, methods, properties, setters, and getters.

Creating a custom

// ECMAscript decorator
function logger(target: any, ctx: ClassDecoratorContext) {
    console.log('logger decorator');
}

@logger
class Person {
    name = 'Max';

    greet() {
        console.log('Hi, I am ' + this.name);
    }
}

To define a type that is a class: <T extends new (...args: any[]) => any)>

Decorators can be used to modify existing classes:

function logger<T extends new (...args: any[]) => any)>(target: T, ctx: ClassDecoratorContext) {
    console.log('logger decorator');

    // e.g. override the previous constructor
    return class extends target {
        constructor(...args: any[]) {
            super(...args);
            console.log('class constructor');
        }
    }
}

@logger
class Person {
    name = 'Max';

    greet() {
        console.log('Hi, I am ' + this.name);
    }
}

The code inside the decorator is executed when it is attached to its target. The remaining code is executed once the class is instantiated.

You can also decorate methods - in this case, we bind methods to the instance of the class:

function autobind(
    target: (...args: any[]) => any, // any method
    ctx: ClassMethodDecoratorContext
) {
    // It works like a constructor
    ctx.addInitializer(function(this: any) {
        // Refers to the instantiated class
        this[ctx.name] = this[ctx.name].bind(this);
    });

    // overwrite the original method
    return function() {
        console.log('add some logging');
        target.apply(this);
    }
}

class Person {
    name = 'Gus';

    @autobind
    greet() {
        console.log('Hi, I am ' + this.name);
    }
}

const p = new Person();
const greet = p.greet;
greet(); // works!

Finally, you can also decorate fields:

// target is undefined because the decorate is executed before it
// has been initialized.
function fieldLogger(target: undefined: ctx: ClassFieldDecoratorContext) {
    console.log(target);

    // You can override the initial value here
    return (initialValue: any) => {
        console.log(initialValue);
        return 'Gustavo';
    };
}

class Person {
    @fieldLogger
    name = 'Gus';

    greet() {
        console.log('Hi, I am ' + this.name);
    }
}

const p = new Person();
p.greet(); // "Hi, I am Gustavo"

A Decorator Factory is simply a function that returns the expression that will be called by the decorator at runtime.

function replacerDecotaror(
    target: (...args: any[]) => any, // any method
    ctx: ClassMethodDecoratorContext
) {
    // do the decoration
}
function replacer(initialValue: any) {
    // Facotory method to create the decorator function.
    return function replacerDecoratr(...);
}

Other notes