Typescript Advanced Concepts

Table of Contents

03. Make TypeScript Class Usage Safer with Strict Property Initialization

03.ts

$ node build/03.js

In a class instance, we may attempt to process an object which is undefined:

class MyClass {
  // may be an array or undefined
  xs: string[];

  constructor() {}
}

const inst = new MyClass()

// compile error, but not TS error
inst.xs.filter(...)

To ensure that TypeScript picks up that we have an unsafe class property, we need the following in tsconfig.json:

...
  compilerOptions: {
    ...
    "strictPropertyInitialization": true,
    "strictNullChecks": true,
    ...
  }
...

strictPropertyInitialization prevents one from cretaing unsage properties in classes.

To address the unsafety we have a few options:

  • allow undefined as a type on the property; requires guards all over our code

  • initialise the property where it is defined

  • initialise the property inside the constructor

  • use TypeScript's definite assignment operator (!) to indicate that a value will be provided to the class when instantiated

    class MyClass {
      // we will definitely have an array to operate on, here, although now we
      // can still experience runtime errors, but we don't require guards in
      // order for for tsc to compile
      definiteProperty!: string[]
    }

04. Use the JavaScript “in” operator for automatic type inference in TypeScript

04.ts

$ node build/04.js

When dealing with union types, we have a few strategies for control flow statements:

  1. assume an object is the object we want to deal with:
function myFunc(obj: Interface1 | Interface2) {
  // assume we've been given an Interface1 object
  if (<Interface1>obj.interface1Prop) {
    // ...
  } else {
    // ...
  }
}

The problem with this is that TypeScript will throw an error if interface1Prop doesn't exist on Interface2.

  1. use a guard
function myFunc(obj: Interface1 | Interface2) {
  /**
   * We're guaranteed an Interface1 object if the predicate returns true now
   */
  if (objectIsInterface1(obj)) {
    // ...
  } else {
    // ...
  }
}

function objectIsInterface1(obj: Interface1 | Interface1): obj is Interface1 {
  // use the assumption here, instead
  return (<Interface1>obj).interface1Prop !== undefined;
}

but this can quickly become cumbersome

  1. use the in operator to infer the type of the object
function myFunc(obj: Interface1 | Interface2) {
  if ("interface1Prop" in obj) {
    // ...
  } else {
    // ...
  }
}

Inside the first block TypeScript will hint the correct properties for that object, while in the second block only the properties for that object will be hinted.

05. Automatically infer TypeScript types in switch statements

05.incorrect.ts

05.correct.ts

$ node build/05.correct.js

In the following example:

interface Action {
  type: string;
}

interface A implements Action {
  readonly type: string = 'A';
  someProp: string;
}

interface B implements Action {
  readonly type: string = 'B';
}

const reducer = (state: State, action: Action) {
  switch(action.type) {
    case 'A': {
      return {...state, someProp: action.someProp}
    }
  }
}

we'd get a type error because someProp is not defined on Action. To fix this:

  1. fix A and Bs interfaces by removing string from type

This has the effect of making type a string-literal type. In addition to being a string-literal type, the readonly operator indicates to TypeScript that we have a value that won't change in future. We have guaranteed to TypeScript that the value of type in our actions won't change.

If readonly were removed, that guarantee would be lost, and TypeScript would again be unable to assist us in properly type-checking the type of each action. 2. create a union of all the actions, and set the action in the reducer's type declaration to that union so that TypeScript knows the reducer will be receiving one of those actions as a type

This is called a discriminated union because each interface shares a common property from which TypeScript can infer the type of an object 3. add a default case that assigns the value of action to a variable with a type of never

This indicates to TypeScript that this case should never happen, and thus indicates to us if we have omitted an action in our cases

The default case has no purpose for the runtime - it's a compile-time check to ensure we're handling all cases

i.e.

interface Action {
  type: string;
}

interface A implements Action {
  // no `type` provided for type property - we now have a unique string literal
  readonly type = 'A';
  someProp: string;
}

interface B implements Action {
  // and here
  readonly type = 'B';
}

// create the union type
type MyActions = A | B;

// use the union type in the reducer's type definition
const reducer = (state: State, action: MyActions) {
  switch(action.type) {
    case 'A': {
      return {...state, someProp: action.someProp}
    }

    // ...

    default: {
      // indicate to the compiler that we should never get here
      const x: never = action;
    }
  }
}

To create a discriminated union:

  1. create 2 or more types that share a property with the same name, type, and is readonly

    This property is called the discriminant.

  2. create a union type containing all of those types

06. Create Explicit and Readable Type Declarations with TypeScript mapped Type Modifiers

06.ts

$ node build/06.js

Using mapped types we can:

  • add properties to types
  • add types to existing properties
  • remove properties from types
  • apply bulk changes to types
interface Person {
  name: string;
  age: number;
  favoriteColor?: string;
}

/**
 * Mark every property in Person as readonly
 */
type PersonReadonly = {
  +readonly [K in keyof Person]: Person[k];
}

/**
 * Remove the optional flag from all properties
 */
type PersonNoOptionals = {
  [K in keyof Person]-?: Person[k];
}

/**
 * Allow string for all properties
 */
type PersonNoOptionals = {
  [K in keyof Person]: Person[k] | string;
}

To use type modifiers:

  • use the type keyword to create a new type, adding the modifier to the body of the declaration

07. Use Types vs. Interfaces

07.ts

$ node build/07.js
  • types are generally used to describe complex objects

  • interfaces are generally used in a more object-oriented fashion to describe the shape of objects

  • two objects that are assigned a type and interface with the same structure can be assigned to one another because TypeScript uses structural typing

  • the following are equivalent:

    interface FnInterface {
      (str: string): void
    }
    type FnType = (str: string) => void
    
    interface ListInterface {
      [key: number]: string;
    }
    type ListType = string[]

    The list interface, however, will not benefit from hinting array functions / typechecking

  • a type can merge both interfaces and types, as can an interface

  • TypeScript will merge the properties of interfaces with the same names:

    interface Foo {
      a: string;
    }
    interface Foo {
      b: string;
    }
    /**
     * foo has properties a and b
     */
    let foo: Foo;

    This is useful for extending libraries without changing the source, while maintaining type-strictness:

    interface JQuery {
      myFunc(): JQuery
    }
    
    // this will typecheck
    $(this).myFunc(...)

    One caveat: this is only possible if the library is authored as an interface. Make sure to author your libraries' public APIs as interfaces to allow others to extend them

08. Build self-referencing type aliases in TypeScript

08.ts

$ node build/08.js
  • interfaces can be self-referencing:

    interface TreeNode<T> {
      value: T;
      left?: TreeNode<T>;
      right?: TreeNode<T>;
    }

    This allows one to continue traversing an item with valid type checking:

    let node: TreeNode<string> = {value: 'foo'}
    
    /**
     * unsafe, but valid TypeScript
     */
    node.left.left.left.value;

09. Simplify iteration of custom data structures in TypeScript with iterators

09.ts

$ node build/09.js

One can create their own iterable object by implementing IterableIterator.

Iterators are not specific to TypeScript; they are a concept built into Javascript, and used in arrays, Symbols, Maps, Generators, etc.

10. Use the TypeScript "unknown" type to avoid runtime errors

10.ts

$ node build/10.js
  • any is the most relaxed type in TypeScript

  • one can assign any type to a value with type any

  • one can access any property on a type declared as any

    e.g.

    const obj: any = '';
    
    // valid
    obj.a.b.c.d
    // or
    obj()

The above demonstrates the problem with any; any user can access any property or even execute a value declared with any, which will present itself as a runtime error, but not a TypeScript error.

An area where this can be problematic is when retrieving data from an API; is the result of one type, or another? By setting the type to any for the result, we have no confidence in what properties may be requested on the result.

To address this, one can use the unknown type. unknown prevents accessing properties on an object without first type-checking the value:

const objAny: any = 'foo';

// valid TypeScript
objAny.a.b.c.d

const objUnknown: unknown = 'bar';

// invalid TypeScript
objUnknown.a

// valid TypeScript
if (objUnknown.hasOwnProperty('a')) {
  console.log(objUnknown.a)
}

11. Dynamically Allocate Function Types with Conditional Types in TypeScript

Conditionally assigning types

11.1.ts

$ node build/11.1.js

One can conditionally assign types to properties using ternary operators:

interface GenericWithAny<T> {
  value: T;
  someProp: any;
}

/**
 * no type hinting / checking on itemAny.someProp
 */
const itemAny: GenericWithAny<string> = {
  value: 'foo',
  someProp: null;
}

interface GenericWithConditional<T> {
  value: T;
  someProp: T extends string ? Array : number;
}

/**
 * We know that someProp must be an array, and we get all the associated hinting
 * and typechecking now
 */
const item1: GenericWithConditional<string> = {
  value: 'bar',
  someProp: null,
}

Conditional types and unions of types

11.2.ts

$ node build/11.2.js

With the following two properties in TypeScript:

  1. conditional types distribute over unions of types
  2. TypeScript "ignores" never types in unions

we have the following:

/**
 * If an array is passed in, return the array of that type, otherwise indicate
 * that the type can never be assigned
 */
 type TypeArray<T> = T extends any[] ? T : never;

/**
 * Create a type that allows only strings or numbers as types within the array
 *
 * Despite adding `string` and `number` to the union, the resulting union is
 *    string[] | number[]
 *
 * because when [1] is applied we get:
 *    never | never | string[] | number[]
 *
 * and then when [2] is applied we get:
 *    string[] | number[]
 */
 type StringsOrNumbers = TypeArray<string | number | string[] | number[]>;

Avoid union types requiring overloaded functions for dynamic type arguments

11.3.ts

$ node build/11.3.js

Instead of returning a simple union from a function, we can use a conditional type to specify which type will be returned.

To improve type-safety, one can specify in the argument of the generic type the shape of the generic we expect, thus preventing the failure statement of the ternary from simply returning the alternative type, regardless of the generic's type:

interface ItemServiceConditionalStrict {
  getItem<T extends string | number>(id: T): T extends string ? Book : Tv;
}

12. Use TypeScript conditional types to create a reusable Flatten type

12.ts

$ node build/12.js
const someObj = {
  id: 1,
  name: 'Sam',
};

// keyof T --> number | string
type Keys<T extends object> = keyof T

// T["id" | "name"] --> T["id"] | T["name"]
type KeysArray<T extends object> = T[keyof T]

13. Infer the Return Type of a Generic Function Type Parameter

13.ts

$ node build/13.js

The infer keyword can be used (only in conjunction with the extends keyword) to infer the type of a function or value:

type UnpackPromise<T> = T extends Promise<infer K>[] ? K : any;
const xs = [Promise.resolve(true)];
type ExpectBoolean = UnpackPromise<typeof xs>; // boolean

TypeScript's ReturnType allows one to get the return type of a function dynamically:

function getValue(seed: number) {
  return number + 5;
}

type Value = ReturnType<typeof getValue>; // number

Comments