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
}
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"
}
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
!

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

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.