Five Lessons I learnt from actually studying TypeScript

I've been using TypeScript for over a year when building applications and suffice it to say, it's won me over. Static type checking has maintained my sanity and dramatically increased my happiness when working with JavaScript. In the beginning, my approach to learning TypeScript included skimming over the official docs, reading a few blog posts, and when stuck, excavating StackOverflow. However, I felt like I was only getting a small taste of what TypeScript offered, so I conducted a structured study of TypeScript by taking both TypeScript Fundamentals and Intermediate TypeScript, which are excellent courses offered on Frontend Masters. In this article I'll write about the most important lessons I've learnt from both these courses. Please note that this article isn't a getting started guide on TypeScript, nor is it an in-depth guide to the concepts outlined below; these are just the patterns that immediately improved the quality of my TypeScript code.

1.The powerful 'never' Type

In TypeScript the type never denotes that no value can exist for a variable that is using never as it's type. never is known as a bottom type. Here's a barebones example of it works:

let neverType:never;
neverType = "Hello"; //TypeScript throws the following error: Type 'string' is not assignable to type 'never'

If you're wondering when you'd use this type, here's a great example:

class Dog {
  bark():void {
    console.log("wooof!");
  }
}

class Cat {
  meow(): void {
    console.log("meoow");
  }
}

type Animal = Dog | Cat; 

function handleBehaviour(animal:Animal):void {
  if (animal instanceof Dog) {
    animal.bark();
  
  } else if (animal instanceof Cat) {
    animal.meow();
  
  } else {
    throw Error(`Unhandled animal type: ${JSON.stringify(animal)}`);
  }
}

var dog = new Dog();
handleBehaviour(dog); //logs: "wooof!"

You've probably come across a more complicated version of the code above; there's a function that encapsulates some logic for certain types. In our example above, handleBehaviour is responsible for invoking a certain action based on the type of the animal provided. If the argument provided is neither a Cat or Dog, an error is thrown. This works well and good, until one day we decide to extend the union of Animal to include Duck like so: Animal = Dog | Cat | Duck. If we forget to add the logic for handling the Duck type in the handleBehaviour function, we're in trouble; we won't know until runtime when the function is called with an instance of Duck and the error is thrown. Wouldn't it be nice if this error was thrown during compile time instead ?

class Dog {
  bark():void {
    console.log("wooof!");
  }
}
class Cat {
  meow(): void {
    console.log("meoow");
  }
}
class Duck { //create a Duck class some months(or years) after the Dog an Cat class were created
  quack():void {
    console.log("quack!");
  }
}

type Animal = Dog | Cat | Duck; //extend the union of Animal to include Duck

function processAnimal(animal:Animal):void {
  if (animal instanceof Dog) {
    animal.bark();
  
  } else if (animal instanceof Cat) {
    animal.meow();
  
  } else {
    //Neither Dog or Cat.
    const neverVal:never = animal; //<-- !!!! ALERT: WE MAKE USE OF 'never' TYPE HERE. 
    // function throws the following compile time error:
    //Type 'Duck' is not assignable to type 'never'.(2322)

    throw new Error(`unhandled animal type: ${JSON.stringify(animal)}`)
  }
}

In the exhaustive conditional above, by simply adding

const neverVal: never = animal

we get compile time error checking and TypeScript throws the following error: Type 'Duck' is not assignable to type 'never' because it's smart enough to know that we forgot to account for Duck in our if/else conditional. Very useful. Very cool.

2.When in doubt, use 'unknown' over 'any'

In contrast to the bottom type we just looked at, TypeScript offer two top types, which are types that can be absolutely anything: *any* and *unknown*.
function foo(val:string) :string { 
  return val.toUpperCase();
}

let anything:any;
anything = "I'm a string!";
anything = null;

anything.can.be.anything.das = 45!; //(line 7) this is perfectly valid
foo(anything); //(line 8) this is perfectly valid.

In the example above, we can assign any value to anything and we can use this value however we like (see lines 7 and 8). When a variable if of type any, we lose all benefits of compile time type checking . There are times when this is necessary, (for example, the parameter type of console.log is any), but in general this should be avoided in favour of it's slightly more onerous brother, unknown.

function foo(val:string) :string { //param must be a string
  return val.toUpperCase();
}

let unknownType: unknown;
unknownType = "I'm a string"; // (line 5)
unknownType = null; // (line 6)

unknownType.can.be.anything.das = "John"; //(line 7) TypeScript throw the following error: Object is of type 'unknown'
foo(unknownType); //(line 8) TypeScript throws the following error: Argument of type 'unknown' is not assignable to parameter of type 'string'

if (typeof unknownType === "string") { 
  foo(unknownType); //(line 9) works after type checking
}

When using the type unknown, TypeScript allows the value to be anything (see lines 5 & 6), but in order to actually use that value, we need to apply a type guard (i.e. check it's type). In the example above, note that on lines 7 and 8 when we try to use unknownType we get a compile time error when we try to use it without first applying a type guard, which goes away after we check it's type.

3.Generous with generics

As the name implies, generics allow the developer to write code that's generalized. Writing code that's reusable across a wide array of scenarios has many obvious advantages: the code is easier to maintain and modify and there's less opportunity for bugs because the TypeScript compiler can perform compile type checks (and also, less opportunity for bugs because there's less code). There's a plethora of articles on the web that will do a great job on getting you up to speed with generics, including the official TypeScript docs

I'll just highlight a use case for how I use generics when building applications, and as the title implies; I'm quite generous. A good rule of thumb I use is that whenever I find myself specifying the return type of a function/class method as any I consider using generics. For example, the return value from making requests to the backend API could be anything. Instead of specifying the return type as any, I built a utility class for handling network requests HttpRequestHandler. Note that the code below is only a simple implementation designed to highlight the usefulness of generics.


/**
 * A generic class that accepts a generic parameter T, which is the type 
 * that's returned from the api. 
 * 
 */
class HttpRequestHandler<T> { 
  readonly path: string;
  constructor(path:string) {
    this.path = path;
  }
  async getData(): Promise<T> { 
   const req = await fetch(this.path);
    const response: HttpResponse<T> = await req.json();

    //validate data
    return response.payload.data;
  }
}
type HttpResponse<T> = {
  payload: {
    data: T
  }
}
//You can now use the HttpRequestHandler class to fetch any kind of data, like so:

const users = await new HttpRequestHandler<Array<User>>(USERS_ENDPOINT).getData();
const firstProduct = await new HttpRequestHandler<Product>(PRODUCT_ENDPOINT).getData();

4.Writing custom type guards

When I first started writing TypeScript, I'd use casting liberally; A classic scenario would be: make a GET request -> parse the response body -> cast it to the appropriate type for the given context.

Here's a simple to illustrate the point:


async function getPersonFromServer(personId:string): Promise<Person> {
  //make get request using the fetch API
  const req = await fetch(`${BASE_URL}/person/${personId}`); //make a GET request using fetch
  const json: unknown = await req.json(); //parse the response body as a JSON object
  return json as Person; //casting the json response as Person without actually checking if it is
  }

The casting occurs on the last line (return json as Person), with the operative word being as. TypeScript will basically say "Alright, you know what you're doing" and will assume that the unknown type is actually the type that it was casted to. Now, this could be all fine and dandy... for now, but what if in the future one of the properties on the response object changes ? Using our example, let's say the backend engineer replaces both the FirstName and lastName on the Person model in the backend with fullName... This ordeal could make for a very traumatic debugging experience and should be all the motivation that one needs to write custom type guards. Here's how they work:


//!!! ALERT: our STRONG custom type guard !!!
function assertPerson(maybePerson: any) : asserts maybePerson is Person  { //<--note the return type

  if (!(maybePerson // maybePerson is not nullish
    && typeof maybePerson === "object" //maybePerson is an object
    && "firstName" in maybePerson && typeof maybePerson["firstName"] === "string" //maybePerson contains the property and the value of the property matches the prop value type
    && "lastName" in maybePerson && typeof maybePerson["lastName"] === "string" 
    && "age" in maybePerson && typeof maybePerson["age"] === "number"
  )) {
    throw new Error(`argument provided is not of type Person. arg value is: ${maybePerson}`)
  } 
}


async function getPersonFromServer(personId:string): Promise<Person> {
  //make get request using the fetch API
  const req = await fetch(`${BASE_URL}/person/${personId}`); //make a GET request using fetch
  const json: any = await req.json(); //parse the response body as a JSON object. NOTE that the type has been changed from 'unknown' to 'any'

 json.fullName; //TypeScript doesn't throw any errors because the type is 'any'
  assertPerson(json); //!!! ALERT -> WE USE THE TYPEGUARD HERE. !!!
json.fullName//TypeScript throws error: Property 'fullName' does not exist on type 'Person'.
  return json;
}

As seen in the example above, the return type of our type guard assertPerson is asserts maybePerson is Person, which is what makes this function a type guard. As for the function body, we're simply checking the object to make sure all requisite property names exist and their value type is what our type expects; and if it doesn't, an error is thrown. I like this approach to type guarding because it's definitive. After the type guard assertion function is invoked, the next line of code will not be executed unless the type checking was a success; if there's . Custom type guards can look quite ugly, so what I like to do is keep mine in a separate file and export the type guard as needed; I usually use these kinds of type guards when I'm parsing the response object from an API.

5.Mighty Mapped Types

Mapped types are types that are created when you transform a type by mapping(i.e. iterating) over it's keys and applying some transformation(s). To better understand this concept, let's look at a less abstract concept by seeing how the map method works in vanilla JS.

 const arr = [1,2,3];
const squared = arr.map((i) => i * i); //outputs [1,4,9];

In the example above, we have our original array, arr, and we create a brand new array, squared by mapping over all elements in arr and applying a transformation, which is multiplying each element by itself. This is a very good mental model for how mapped types work; let's start by analyzing a very simple example of they work.

type User = { //create a simple type, 'User'
  name:string;
  age:number;
}

const user : User = { //create an object of type 'User'
  name:"John",
  age: 29
}

type UserValidator = { // <--- !!!! ALERT OUR MAPPED TYPE !!!! 
  [K in keyof User as`is${Capitalize<K>}Valid`]: boolean;
} 
/**returns the following type: {
  ** isNameValid: boolean;
  ** isAgeValid: boolean;
   */}

//make use of our mapped type:
function areUserPropsValid(user: User) : UserValidator  {
  //some rigorous process to determine whether all props are set 
}

In the code above we have our original type alias called User and our derived type(i.e mapped type) called UserValidator. We can imagine a scenario where we have a complicated object and an equally complicated process of validating the values of it's properties. Instead of creating a new type to represent the validation process (which would be the naive approach) , we use mapped types to transform our existing type(User) into a new type (UserValidator) by applying a transformation (modifying property names and setting the value of all properties to be of type boolean). The Capitalize type is a utility type provided by TypeScript, and is one of many utility types for your common everyday type transformations needs. If you want to learn more about how mapped types work, I recommend learning from the official docs;TypeScript has a very digestible page on mapped types.