Photo by Philippe Toupet on Unsplash

TypeScript's generic types are very powerful – mostly because TypeScript was created to statically type dynamic JavaScript code. In this post we'll take a look at a couple more advanced features of generics – generic constraints and default values.

This post is not meant to be an introduction to Generics. For that, read our TypeScript: Basics of Generics blog post.

A Glimpse into Hopjump's Telemetry Events

Hopjump uses telemetry events on the site to analyze some user actions, such as which pages are visited or which hotels are viewed. Each of these events have data associated with them, which we refer to as "attributes". Different events have different attributes.

Here's an example of what the simplified API looks like in JavaScript:

const pageview = stamp(
  new Pageview({ url: "/" }),
)

const hotelImpression = stamp(
  new HotelImpression({
    id: 123,
    name: "Yotel Boston",
    position: 3,
  }),
)

Both HotelImpression and Pageview are classes that are constructed with different attributes. They are both passed to the stamp function, which sends the events to the server, where the information is recorded. Finally, the event instance is returned from the stamp function so the event can be used in the rest of the program.

Here's a simplified implementation of the event classes:

class StamperEvent {
  constructor(attrs) {
    this.attrs = attrs
  }
}

class Pageview extends StamperEvent {
  type = "Pageview"
}

class HotelImpression extends StamperEvent {
  type = "HotelImpression"
}

And here's a simplified implementation of the stamp function:

function stamp(event) {
  console.log("Stamping event!", event.type, event.attrs) 
  return event
}
In reality this function will POST to a server instead of logging.

Adding static types

How would we add static types to these functions? Well, we want stamp to only accept StamperEvent instances. Here's an attempt to do that:

function stamp(event: StamperEvent): StamperEvent {
  console.log("Stamping event!", event.type, event.attrs)
  return event
}

Great, now let's look at the types of the calling code:

const pageview = stamp(
  new Pageview({ url: "/" })
)
// type of pageview is StamperEvent

While this is true, it's not the most accurate type for that variable. We know the type of the pageview variable should be Pageview, but because we're returning a StamperEvent type from stamp we lose that type precision. Adding generics can solve this!

function stamp<T>(event: T): T {
  console.log("Stamping event!", event.type, event.attrs)
  return event
}

This now has the same type signature as the identity function – which returns whatever type it's given. This fixes the type of the pageview variable, but we have another problem:

We've lost the ability to access type and attrs because now there's no guarantee that the event passed in is of type StamperEvent. Luckily, generic constraints can solve this issue.

Generic Constraints

We can constrain the T type by attaching a restriction to it – it must extend StamperEvent:

function stamp<T extends StamperEvent>(event: T): T {
  console.log("Stamping event!", event.type, event.attrs)
  return event
}

Great! This now enforces that every argument passed into stamp is a subtype of StamperEvent, and TypeScript now allows us to call event.type and event.attrs within the function. Since we're returning type T, the pageview variable now correctly has the Pageview type.

Adding Types to Attributes

Earlier I mentioned that each event has it's own attribute types. For instance, pageviews have url and hotel impressions have id, name, and position. We would like to add TypeScript types to those attributes as well.

abstract class StamperEvent {
  abstract type: string
  attrs: any // can we make this better?

  constructor(attrs: any) {
    this.attrs = attrs
  }
}

class Pageview extends StamperEvent {
  type = "Pageview"
}

class HotelImpression extends StamperEvent {
  type = "HotelImpression"
}
Don't worry too much about the abstract part – StamperEvent really only makes sense as a superclass so we've made it abstract.

Since each subclass has a different type for attrs, we can make it a generic class variable:

abstract class StamperEvent<Attrs> {
  abstract type: string
  attrs: Attrs

  constructor(attrs: Attrs) {
    this.attrs = attrs
  }
}

class Pageview extends StamperEvent<{ url: string }> {
  type = "Pageview"
}

class HotelImpression extends StamperEvent<{
  id: number
  name: string
  position: number
}> {
  type = "HotelImpression"
}
Attrs is a more descriptive name for the generic type. We could have instead used T.

Great – now we can initialize Pageview and HotelImpression classes and get type checking for attrs!

IntelliSense knows which attrs are valid, and their types!

However, this now adds an error to our stamp function:

The StamperEvent class now requires a generic type argument.

Generic Default Values

In the case of the stamp function, we don't actually care about the attribute types on the event passed in. We simply print them out (and in the future, POST to the server). We can fix this by passing any to StamperEvent:

function stamp<T extends StamperEvent<any>>(event: T): T {
  ...
}

Another way to accomplish this is to set a default generic value on the StamperEvent class:

abstract class StamperEvent<Attrs = any> {
  ...
}

Now, StamperEvent can be referred to without a generic type argument – if it is, it will use any as the default value. But, if it's passed a different generic type it will use that instead!

Here's the final code:

abstract class StamperEvent<Attrs = any> {
  abstract type: string
  attrs: Attrs

  constructor(attrs: Attrs) {
    this.attrs = attrs
  }
}

class Pageview extends StamperEvent<{ url: string }> {
  type = "Pageview"
}

class HotelImpression extends StamperEvent<{
  id: number
  name: string
  position: number
}> {
  type = "HotelImpression"
}
  
function stamp<T extends StamperEvent>(event: T): T {
  console.log("Stamping event!", event.type, event.attrs) 
  return event
}

const hotelImpression = stamp(
  new HotelImpression({
    id: 123,
    name: "Yotel Boston",
    position: 3,
  }),
)
const pageview = stamp(new Pageview({ url: "/" }))

I hope this helps you understand the power and flexibility of TypeScript generics! For additional information and examples see the TypeScript documentation on generics.