Typescript Section 7 – Generics


TypeScript

Updated Aug 29th, 2022

Table of Contents

Chapter Details

Module Introduction

Concept exists in some other programming languages (C#, Java) and from the docs, “A major part of software engineering is building components that not only have well-defined and consistent APIs, but are also reusable.” It goes on to say “capable of working on the data of today as well as the data of tomorrow.” Generics provide flexibility and help “create a component that can work over a variety of types rather than a single one. This allows users to consume these components and use their own types.”

Built-in Generics & What are Generics?

Default generic type built into TS which we already worked with and that is an array.

Example of an array with strings could be thought of two types combined. Array could be a type of its own (the same way an object is a type of its own), and what is stored inside may have types of their own.

const names: Array<string> = []
// Above is the same as const names: string[]

Generic is a type which is kind of connected with some other type but flexible with which other type that is. This way TS can give us better support. If we know it’s strings, TS will not complain when we run string methods (like “.split()”).

Between angle brackets you say what type of data will be stored inside.

Another generic type is the “promise” type, this is a JS feature, not a TS feature.

Tells TS the Promise will return with a type string

const promise: Promise<string> = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve("this is done")
  }, 2000)
})

Promise<any> example doesn’t give us any TS support. Can call “.split()” on a number, but we should not be able to since this only works with strings, and we want TS to warn us. So we switch “<any>” or “<unknown>”, which is the inferred default, for “<string>.”

const promise: Promise<string> = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve(10)
  }, 2000)
})

promise.then(data => {
  data.split(' ') //gives us a warning now
})

Better type safety with generic types.

Creating a Generic Function

We can create both generic functions and classes.

function merge(objA: object, objB: object) {
  return Object.assign(objA, objB)
}

const mergedObj = merge({name: "Max"}, {age: 30})
// mergedObj.age will get TS error

Why is the warning thrown in the code above? TS doesn’t know the age key is on the merged object. TS knows we get back an object but no idea what is inside.

We could use “Type casting” (using “as” keyword or react-like syntax) but it is really cumbersome to have to do that.

So we refactor by adding, immediately after the function name and before the parameters, angle brackets and two identifiers.

“T”, can be named anything but stands for “type” and says what we get here is of type “T.”

We accept a second generic parameter or type you might be using is named “U.” We use the letter “U” by convention because it is the next letter in the alphabet, (similar to for loops using let i, j, and k).

function merge<T, U>(objA: T, objB: U) {
  return Object.assign(objA, objB)
}

const mergedObj = merge({name: "Max"}, {age: 30})

console.log(mergedObj.age) // this now works: result of 30

So what does this do for us? A lot actually. If we hover over the “merge” function we see TS infers that this function “returns the intersection of T and U” , (intersection of types denoted with ampersand).

function merge<T, U>(objA: T, objB: U): T & U

We could have set explicitly “T & U” before the curly brackets but we don’t need to explicitly do that.

So how is this helpful? If we hover over “mergedObj” we see that TS understands what we store is the intersection of these two object types.

Why can it infer now and not with “object” like we did before? Well “object” is a highly unspecific type.

Can and often will be of different types. Now TS knows we will get different “types” of data but it will be the intersection of data.

Can also tell TS specifically which types is should fill when you call it, by adding angle brackets after the function name when you call it, and then you fill in your own types for “T” and “U.” But TS may complain if the concrete values don’t match the “type” specified.

const mergedObj = merge<string, number>({name: "Max", hobbies: ["Sports"]}, {age: 30})

So we could have this

const mergedObj = merge<{name: string, hobbies: string[]}, {age: number}>({name: "Max", hobbies: ["Sports"]}, {age: 30})

But this is redundant. So this is what generics are all about, not needing to be so verbose. TS will infer the types of the values we are passing as arguments and then plugs in the inferred types for “T” and “U” for this function call.

Working with Constraints

Pass some extra information in to this “merge” function so we can work in a better way with the result, allows you to continue working with data in a TS optimal way.

Example shown of problem with passing number 30 instead of an object into the merge function from the previous chapter. Fails silently as JS throws no error. Can’t merge because it because it is not an object.

Currently we are saying T and U can be of “any” type, and sometimes that is not what we want. We would rather specifically say “T” and “U” should be of a certain type, and we do that with the “extends” keyword. We guarantee objects are passed as arguments. Can also use union types for this (union types uses the pipe operator). Set any constraints you want and can do this for both or for just “T” or just “U.”

function merge<T extends object, U extends object>(objA: T, objB: U) {
  return Object.assign(objA, objB)
}

const mergedObj = merge({name: "Max", hobbies: ["Sports"]}, 30)

// this now throws compilation error since 30 is not an object
// forced to pass in an object based on syntax above

This concept of “constraints” can be very helpful.

Another Generic Function

“Generics” can be strange and tough to wrap your head around but they really are straight forward.

function countAndDescribe<T>(element: T) {
  let descriptionText = "Got no value"
  if (element.length === 1) {
    descriptionText = "Got 1 element."  
  } else if (element.length > 1) {
    descriptionText = "Got " + element.length + " elements."
  }
  return [element, descriptionText]
}

TS will complain about length because it is not clear that “element” will have a “length.”

interface Lengthy {
  length: number
}

function countAndDescribe<T extends Lengthy>(element: T): [T, string] {
  let descriptionText = "Got no value"
  if (element.length === 1) {
    descriptionText = "Got " + element.length + " element1."  
  } elseif (element.length > 1) {
    descriptionText = "Got " + element.length + " elements."
  }
  return [element, descriptionText]
}

console.log(countAndDescribe("Hi there.")) // works

The “[T, string]” code is saying we return a tuple where the first element is any element of our generic type “T” and second element will be of type “string.”

Could also call with an array. Cannot call with a number since a number has no “length” property.

So again, we want to be as unspecific as possible but it does need to have a “length” property.

The “keyof” Constraint

One specific type of constraint you can add is the “keyof” constraint.

In the function below, the first parameter should be any type of object and the second parameter should be any type of key in that object.

function whatEvs<T extends object, U extends keyof T>(obj: T, key: U) {...}

An “extractAndConvert” function example

Not sure if an object will have a “key” so TS throws and error

function extractAndConvert<T extends object, U extends keyof T>(obj: T, key: U) {
  return "Value" + obj[key]
}

extarctAndConvert({name: "Max"}, "name")

// will get an error unless there is an object with a "name key" in the first argument 

Want to tell TS we want to ensure that we have a correct structure, allows us to not make dumb mistakes by trying to calling function that would try to access a property that doesn’t exist.

Generic Classes

DataStorage class example, (the word “storage” is a reserved name).

class DataStorage {
  private data = []

  addItem(item) {
    this.data.push(item)
  }

  removeItem(item) {
    this.data.splice(this.data.indexOf(item), 1)
  }

  getItems() {
    return [...this.data]
  }
}

But we get a lot of errors so refactor:

class DataStorage<T> {
  private data: T[] = []

  addItem(item: T) {
    this.data.push(item)
  }

  removeItem(item: T) {
    this.data.splice(this.data.indexOf(item), 1)
  }

  getItems() {
    return [...this.data]
  }
}

Why bother? Maybe we don’t just want to store text, we may also want to store numbers in a different “DataStorage” object. Using generics in our class definition allows us to be flexible but still with Typescript support (strongly-typed).

const textStorage = new DataStorage<string>()
textStorage.addItem("Max")

const numberStorage = new DataStorage<number>()
textStorage.addItem(23)

Note:

Note: A problem with the implementation of above is if you try to use objects as the type for the generic you may get unwanted results because of JS objects being reference-types/pointers and not primitives. In the example above, the class has a “removeItem” method that is using “indexOf” and it will not work well with non-primitive values, like objects or arrays. This is because with “indexOf,” passing in an object, our code is technically creating brand new objects in memory with a different address. It looks at the address, doesn’t find it and removes the last element in the array since “indexOf” returns -1 if it cannot find anything, and -1 means to start at the end of the array and removes the last element in the array. We can refactor a bit or just setup to not accept objects by only accepting numbers and string. Objects in JS are “reference types” instead of “primitive types” (This is a JS concept).

The indexOf() method returns the position of the first occurrence of a value in a string. The indexOf() method returns -1 if the value is not found.

Refactor option #1: Makes sure we don’t remove the wrong item but code still doesn’t remove the right item.

removeItem(item) {
  if (this.data.indexOf(item) === -1) {
    return 
  }
  this.data.splice(this.data.indexOf(item), 1)
}

Refactor option #2: Pass it the same object in memory again by storing in a constant.

const maxObj = {name: "Max"}
objStorage.addItem(maxObj)
objStorage.removeItem(maxObj)

Refactor option #3: Just have it work with primitive values: strings, numbers, booleans.

class DataStorage<T extends string | number | boolean> {...}

A First Summary

Generic types can be tricky but give us flexibility with safety.

Generic Utility Types

A little bonus, there are some built-in types that utilize generic types that give us “utility” functionalities that may come in handy. Only exist in TS world, useful prior to compilation and not compiled to anything. Give us extra checks or skip certain checks. See the docs here for a list of these with more explanation.

“Partial” type: Temporarily makes all object properties optional.

interface CourseGoal {
  title: string;
  description: string;
  completeUntil: Date;
}

function createCourseGoal(
  title: string,
  description: string,
  date: Date
): CourseGoal {
  let courseGoal: Partial<CourseGoal> = {}
  courseGoal.title = title;
  courseGoal.description = description;
  courseGoal.completeUntil = date;
  // does end up as a CourseGoal with typecasting
  return courseGoal as CourseGoal
}

In the example above, we are starting with an empty object and then manually adding each property step by step. Would do it this way for maybe extra validation between each step.

“Readonly” type: Will make sure you have a locked array. Can’t add or remove with push/pop. Can use “Readonly” with objects as well.

const names: Readonly<string[]> = ["Max", "Anna"]

// names.push("Manu") throws TS warning
// names.pop() throws TS warning

Generic Types vs Union Types

Once common source of confusion is the difference between generics and union types.

Could write the “DataStorage” class above with “union types” and it may look like it achieves the same thing but it is not the same.

Union types are good when you want to have a function where you can call with any of a few types. Allows “mix” of types.

Generics are great way to allow flexibility but choose once and lock in a certain type and use the same type throughout the entire function or throughout the entire class instance.

Useful Resources & Links