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