Skip to content

typescript v3

Jorge Estanislao Barsoba edited this page Dec 1, 2022 · 4 revisions

Must-Knows - TypeScript - Fundamentals

Introduction

Syntactic superset of JavaScript

  • Compiles to readable JavaScript
  • Its goal is to add types to JavaScript
  • Three parts:
    • Language
    • Language Server
    • Compiler
  • Benefits:
    • Makes the author's intent more clear
      E.g. This does not tell if it's numerical addition or string concatenation.

      function add(a, b) { return a + b;}

      But this one does:

      function add(a: string, b: string) { return a + b;}
    • Moves some errors from runtime to compile time

    • Autocomplete like the one we have in C#; Where you can get suggestions based on the behaviour of an abject

Setup

  • Files:
    • package.json
      {
          "devDependencies": {
              "typescript: "^4.5"
          },
          "scripts": {
              "dev": "tsc --watch --preserveWatchOutput"
          }
      }
    • tsconfig.json
      {
          "compilerOptions": {
              "outDir": "dist", // Where to put the .ts files
              "target": "ES3",
              "declaration": true // Generate declaration files (.d.ts)
          },
          "include": ["src"] // Which files to compile
      }
  • Summary:
    • .ts: code that runs + type info
      • .js: code that runs
      • .d.ts: type info

    If we set target as ES2017, the .ts and .js files look almost identical, except for the types

Variables

  • With let age = 6, TypeScript infers that age is a number (unlike C#)

    From this point on, the compiler won't let us do age = "not a number; This is one example of what's TypeScript designed for

  • const age = 6 is not a number, but a literal type; Because:
    • const cannot be reassigned
    • number is an inmutable value type, unlike array, which is a value type you can push things to (bcs it's mutable)

Types

  • any is the most flexible type in TypeScript, like the vanilla JavaScript variable rules

    Whatever type you like | any is any!

  • let endTime: Date has a type annotation, to enforce types

When I define return types like this function add(a: number, b: number): number {}, I state my intentions upfront; i.e. When we're declaring the function, and not when we're using it

  • To type an car Object like "2002 Toyota Corolla" we can do:
    {
        make: string
        model: string
        year: number
        chargeVoltage?: number // Optional
    }

We can use an if (typeof car.chargeVoltage !== "undefined") as a Type guard, and TypeScript compiler will know that inside that branch of code, chargeVoltage is a number

  • The TypeScript compiler provides Excess property checking, to ensure we don't pass extra properties that will not be safely accessible from inside the function
  • To declare dictionary we can use the Index signature:
    {
        const phones: {
            [k: string]: {
                country: string
                area: string
                number: string
            } | undefined // This prevents us from writing things like phones.fax...
        } = {}
    }
  • Array types are as easy as adding [] after the array's member type, like string[]
  • Tuples can be defined like let myCar: [number, string, number], where we define the type we expect for each position
  • Union type is like OR for types. e.g. function flipCoin(): "heads" | "tails" {}, or
      const result: Error | {
          name: string;
          email: string;
      }
      // result.name will compile; But result.email will not, as email does not exist in Error
      // So, we can only access the guaranteed stuff, whether it's an Error or that specific Object
    • We can use type guards to narrow down and increase specificity like:
      if (result instanceOf Error) {
          // result.name will compile, but result.email will not...
      }
      else {
          // result.email will compile here!
      }
    • Discriminated or "tagged" union type: Combine the use of type guards and a discriminating key, to switch between different possibilities; Suppose we have a "tuple" return type like:
      function maybeGetUserInfo():
        | ["error", Error]
        | ["success", { name: string; email: string  }] {}
      
      const outcome = maybeGetUserInfo();
      if (outcome[0] === "error") {
          // outcome is an error...
      } else {
          // outcome is the user info object...
      }

      Here TypeScript understands that if outcome has the "error" string on its first position, the tuple is definitely of type ["error", Error]

  • Intersection type is like AND for types; e.g.:
    function makeWeek(): Date & { end: Date } {
        // do stuff...
        
        return { ...start, end }; // start is a Date, and end too...
      }
    
    const thisWeek = makeWeek();
    thisWeek.toISOString();
    thisWeek.end.toISOString();
  • Type aliases:
    • Allow us to give more meaningful names to our types (semantic)
    • Define types in a single place
    • Use export/import
      // types.ts
      export type UserContactInfo = {
          name: string
          email: string
      }
      
      //utilities.ts
      import {UserContactInfo } from "./types"
      
      function printContactInfo(info: UserContactInfo) {}
    • Inheritance-like implementation, by combining a type alias and an intersection type: Combine existing types with new behavior
    type SpecialDate = Date & { getReason(): string }
  • Interfaces: A way of defining an object type; An object type looks like a "class instance"
    • Inheritance:

      • extends: Describes inheritance between "like" things
        • Classes can extend from other classes
        • Interfaces can extend from other interfaces
      • implements: Describes inheritance between "unlike" things
        • Classes can implement interfaces
          interface AnimalLike {
              eat(food): void
          }
          
          class Dog implements AnimalLike {
              eat(food) {}
          
              bark() { return "woof" }
          }

        Interfaces are a great way to define contracts between things

    • Interfaces in TypeScript are open:

      interface AnimalLike {
          isAlive(): boolean
      }
      
      function feed(animal: AnimalLike) {
      
      }
      
      // Second declaration with same name!
      interface AnimalLike {
          eat(food): void
      }

      We could "augment" the global Window interface like this:

      window.document // existing
      window.exampleProperty = 42 // new
      
      interface Window {
          exampleProperty: number
      }
  • Recursive types:
    type NestedNumbers = number | NestedNumbers[]
    
    const val: NestedNumbers = [3, 4, [5, 6, [7]], 221]
  • Exercise / example on how to describe JSON using types:
    // @errors: 2578
    type JSONObject = { [k: string]: JSONValue }
    type JSONArray = JSONValue[]
    type JSONValue = number | string | boolean | null | JSONObject | JSONArray
    
    ////// DO NOT EDIT ANY CODE BELOW THIS LINE //////
    function isJSON(arg: JSONValue) { }
    
    // POSITIVE test cases (must pass)
    isJSON("hello")
    isJSON([4, 8, 15, 16, 23, 42])
    isJSON({ greeting: "hello" })
    isJSON(false)
    isJSON(true)
    isJSON(null)
    isJSON({ a: { b: [2, 3, "foo"] } })
    
    // NEGATIVE test cases (must fail)
    // @ts-expect-error
    isJSON(() => "")
    // @ts-expect-error
    isJSON(class { })
    // @ts-expect-error
    isJSON(undefined)
    // @ts-expect-error
    isJSON(new BigInt(143))
    // @ts-expect-error
    isJSON(isJSON)

Functions

  • Call signatures:

    // With interfaces...
    interface TwoNumberCalculation {
      (x: number, y: number): number
    }
    
    // With types...
    type TwoNumberCalc = (x: number, y: number) => number
    
    const add: TwoNumberCalculation = (a, b) => a + b
    
    const subtract: TwoNumberCalc = (a, b) => a - b

    The return value of a void function is intended to be ignored; On the other hand, if we use undefined as a return type instead, then the compiler will specifically expect an undefined to be returned

  • Construct signatures: Define what should happen with the new keyword

    interface DateConstructor {
      new (value: number): Date
    }
    
    let MyDateConstructor: DateConstructor = Date
    const d = new MyDateConstructor()
  • Function overloads: E.g. Type-check calls to the handleMainEvent function, which supports exactly two overloads:

    type FormSubmitHandler = (data: FormData) => void // Handler for the form
    type MessageHandler = (evt: MessageEvent) => void // Handler for the iFrame
    
    // Without Function overloads: A single implementation that handles all argument types...
    function handleMainEvent(
      elem: HTMLFormElement | HTMLIFrameElement,
      handler: FormSubmitHandler | MessageHandler
    )
    
    // With Function overloads: One head per each arguments valid combination, and a single generic implementation...
    function handleMainEvent(
      elem: HTMLFormElement,
      handler: FormSubmitHandler
    )
    function handleMainEvent(
      elem: HTMLIFrameElement,
      handler: MessageHandler
    )
    function handleMainEvent(
      elem: HTMLFormElement | HTMLIFrameElement,
      handler: FormSubmitHandler | MessageHandler
    ) {
      if (typeof elem == HTMLFormElement) {
    
      }
      else {
    
      }
    }
  • this types: Instead of having any as the type of this, we can define a type for it when defining a function:

    function myClickHandler(
      this: HTMLButtonElement,
      event: Event
    ) {}
    
    // Once `this` type is defined, the function must be called using `call`...
    myClickHandler.call(myButton, new Event("click"));

Classes

  • On top of JavScript classes, TypeScript allows us to declare the members of the class, with its types:
    class Car {
      public make: string
      public model: string
      #year: number // The "#" symbol is from ECMAScript's private identifier
    
      constructor(make: string, model: string, year: number) {
        this.make = make;
        this.model = model;
        this.year = year;
      }
    }
  • Access modifiers:
    • public: everyone (default)
    • protected: instance and subclasses
    • private: instance only

      "private" provides compile-time encapsulation; While "#" brings runtime encapsulation, which makes it harder to get access to Limited exposure is a technique where we take sth that is hidden, and provide controlled access to it; Like exposing a private thing through a protected method

  • Param properties: access modifiers before constructor arguments
    • Implicitly defines class members
    • Can significantly reduce the Car declaration code:
      class Car {
        constructor(public make: string, public model: string, public year: number) {}
      }

Top types

  • Types describe a set of allowed values that a value might be; E.g.:
    let a: 5 | 6  | 7 // anything in { 5, 6, 7 }
    let b: null // anything in { null }
    let c: boolean // anything in { true, false }
  • Top types describe the most general thing that exists in this type system:
    • any
    • unknown, unlike any, cannot be used without first applying a type guard

    A practical use of top types is when converting a project from JavaScript to TypeScript

  • Bottom types describe no possible value; Like saying "you can pick anything you wish from this empty box"
    • never, where the only thing that can fit into a never is a never
    • A practical use of never is for implementing Exhaustive conditionals (enforced in Rust, BTW):
      // The exhaustive conditional
      if (myVehicle instanceof Truck) {
        myVehicle.tow() // Truck
      } else if (myVehicle instanceof Car) {
        myVehicle.drive() // Car
      } else {
        // NEITHER!
        throw new UnreachableError(
          myVehicle,
          `Unexpected vehicle type: ${myVehicle}`
        ) // Argument of type 'Boat' is not assignable to parameter of type 'never'
      }

Type guards

  • Besides instanceof, TypeScript's built-in type guards also include typeof and Array.isArray
  • User-defined type guards: We're telling TypeScript to follow our instructions when it comes to evaluate the type of a variable
    interface CarLike {
      make: string
      model: string
      year: number
    }
    
    let maybeCar: unknown
    
    // the guard
    function isCarLike(
      valueToTest: any
    ): valueToTest is CarLike { // key syntax here is `valueToTest is CarLike`
      return (
        valueToTest &&
        typeof valueToTest === "object" &&
        "make" in valueToTest &&
        typeof valueToTest["make"] === "string" &&
        "model" in valueToTest &&
        typeof valueToTest["model"] === "string" &&
        "year" in valueToTest &&
        typeof valueToTest["year"] === "number"
      )
    }
    
    // using the guard
    if (isCarLike(maybeCar)) {
      maybeCar
        
    let maybeCar: CarLike
    }

Nullish values

  • Nullish values are:
    • null: there is a value, and that value is nothing
    • undefined: the value isn't available (yet?)
    • void: the return value of the function should be ignored
  • Non-null assertion operator: Tells TypeScript to ignore the possibility of a value to be null or undefined; If that's the case, it'll throw a runtime error!
    cart.fruits!.push({ name: "kumkuat", qty: 1 })
  • Definite assignment operator: !: operator is used to supresses TypeScript's objections about a class field being used, when it can't be proven that it was initialized

Generics

  • A way of creating types that are expressed in terms of other types
  • Example: Converting a "list of things" into a "dictionary of things"
    // Dictionary definition
    const phones: {
      [k: string]: {
        customerId: string
        areaCode: string
        num: string
      }
    } = {}
    
    // "List of things"
    const phoneList = [
      { customerId: "0001", areaCode: "321", num: "123-4566" },
      { customerId: "0002", areaCode: "174", num: "142-3626" },
      { customerId: "0003", areaCode: "192", num: "012-7190" },
      { customerId: "0005", areaCode: "402", num: "652-5782" },
      { customerId: "0004", areaCode: "301", num: "184-8501" },
    ]
    
    // We'd like to convert it into a "Dictionary of things"...
    const phoneDict = {
      "0001": {
        customerId: "0001",
        areaCode: "321",
        num: "123-4566",
      },
      "0002": {
        customerId: "0002",
        areaCode: "174",
        num: "142-3626",
      },
      /*... and so on */
    }
    
    // Implementation using `any`, where we lose the type information...
    function listToDict(
      list: any[],
      idGen: (arg: any) => string // To obtain a “key” from each item in the list
    ): { [k: string]: any } {
      const dict: { [k: string]: any } = {}
      list.forEach((element) => {
        const dictKey = idGen(element)
        dict[dictKey] = element
      })
      return dict
    }
    
    const dict = listToDict(
      [{ name: "Mike" }, { name: "Mark" }],
      (item) => item.name
    )
    console.log(dict)
    dict.Mike.I.should.not.be.able.to.do.this.NOOOOOOO
    
    // We need a way to define the relationship between the type we receive and the type we return, and this is what generics is about...
    function listToDict<T>( // T is a "type parameter"
      list: T[],
      idGen: (arg: T) => string
    ): { [k: string]: T } { // It auto-detects what T should be, and returning the right thing to us...
      const dict: { [k: string]: T } = {}
      list.forEach((element) => {
        const dictKey = idGen(element)
        dict[dictKey] = element
      })
      return dict
    }
  • Exercise / example on implementing map, filter and reduce for dictionaries:
    /////////////////////////////////////////
    /////////// TESTING UTILITIES ///////////
    //////// no need to modify these ////////
    /////////////////////////////////////////
    // @errors: 7006 7006 7006 7006 7006
    console.clear()
    
    function assertEquals<T>(
      found: T,
      expected: T,
      message: string
    ) {
      if (found !== expected)
        throw new Error(
          `❌ Assertion failed: ${message}\nexpected: ${expected}\nfound: ${found}`
        )
      console.log(`✅ OK ${message}`)
    }
    
    function assertOk(value: any, message: string) {
      if (!value)
        throw new Error(`❌ Assertion failed: ${message}`)
      console.log(`✅ OK ${message}`)
    }
    /// ---cut---
    
    ///// SAMPLE DATA FOR YOUR EXPERIMENTATION PLEASURE (do not modify)
    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, U>(
      dict: Dict<T>,
      fn: (obj: T, name: string) => U): Dict<U> {
      let toReturn: Dict<U> = {};
      for (const key in dict) {
        toReturn[key] = fn(dict[key], key);
      }
      return toReturn;
    }
    // Array.prototype.filter, but for Dict
    function filterDict<T>(
      dict: Dict<T>,
      fn: (obj: T) => boolean): Dict<T> {
      let toReturn: Dict<T> = {};
      for (const key in dict) {
        if (fn(dict[key])) {
          toReturn[key] = dict[key];
        }
      }
      return toReturn;
    }
    // Array.prototype.reduce, but for Dict
    function reduceDict<T, V>(
      dict: Dict<T>,
      fn: (currentValue: V, item: T) => V,
      initialValue: V): V {
      let returnValue = initialValue;
      for (const key in dict) {
        returnValue = fn(returnValue, dict[key]);
      }
      return returnValue;
    }
    
    /////////////////////////////////////////
    ///////////// TEST SUITE ///////////////
    //////// no need to modify these ////////
    /////////////////////////////////////////
    
    // MAP
    const fruitsWithKgMass = mapDict(fruits, (fruit, name) => ({
      ...fruit,
      kg: 0.001 * fruit.mass,
      name,
    }))
    const lemonName: string = fruitsWithKgMass.lemon.name
    // @ts-ignore-error
    const failLemonName: number = fruitsWithKgMass.lemon.name
    assertOk(
      fruitsWithKgMass,
      "[MAP] mapDict returns something truthy"
    )
    assertEquals(
      fruitsWithKgMass.cherry.name,
      "cherry",
      '[MAP] .cherry has a "name" property with value "cherry"'
    )
    assertEquals(
      fruitsWithKgMass.cherry.kg,
      0.005,
      '[MAP] .cherry has a "kg" property with value 0.005'
    )
    assertEquals(
      fruitsWithKgMass.cherry.mass,
      5,
      '[MAP] .cherry has a "mass" property with value 5'
    )
    assertEquals(
      Object.keys(fruitsWithKgMass).length,
      8,
      "[MAP] fruitsWithKgMass should have 8 keys"
    )
    
    // FILTER
    // only red fruits
    const redFruits = filterDict(
      fruits,
      (fruit) => fruit.color === "red"
    )
    assertOk(
      redFruits,
      "[FILTER] filterDict returns something truthy"
    )
    assertEquals(
      Object.keys(redFruits).length,
      4,
      "[FILTER] 4 fruits that satisfy the filter"
    )
    assertEquals(
      Object.keys(redFruits).sort().join(", "),
      "apple, cherry, grape, raspberry",
      '[FILTER] Keys are "apple, cherry, grape, raspberry"'
    )
    
    // REDUCE
    // If we had one of each fruit, how much would the total mass be?
    const oneOfEachFruitMass = reduceDict(
      fruits,
      (currentMass, fruit) => currentMass + fruit.mass,
      0
    )
    assertOk(
      redFruits,
      "[REDUCE] reduceDict returns something truthy"
    )
    assertEquals(
      typeof oneOfEachFruitMass,
      "number",
      "[REDUCE] reduceDict returns a number"
    )
    assertEquals(
      oneOfEachFruitMass,
      817,
      "[REDUCE] 817g mass if we had one of each fruit"
    )

Generics scopes and constraints

  • To describe constraints on generics, we use <T extends HasId>; What we're saying is "I can be given any T, but it has to at least meet this base requirement"
  • Just like function parameters, "inner scopes can see outer scopes", type params work a similar way

Clone this wiki locally