Working with this in TypeScript

By Simon Smithin TypeScript

11 min read

The this keyword, a concept that I still see cause confusion amongst developers, probably due to classes and OOP mostly being disregarded these days. However, it pays to have an understanding as it still lurks in codebases.

We can apply a general rule to help get us started:

Most of the time, the this keyword points to the object that a function belongs to

Functions belonging to objects

We'll come back to some of the exceptions, but let's start with the most common case, which is when a function belongs to an object or a class instance:

const job = {
  title: 'Frontend Developer',
  display() {
    console.log(this.title)
  },
}

job.display() // Frontend Developer

This is one of the simplest examples, and we can see here that this refers to the job object. In fact, it's possible to just simply replace the keyword with job and it would behave the same:

const job = {
  title: 'Frontend Developer',
  display() {
    console.log(job.title)
    // --------- ^
  },
}

Whilst its possible to reference the property directly with job, this is not a common approach. You are more likely to see the use of the this keyword.

A reason to prefer this can be demonstrated a bit more clearly when using a class and creating an object with the new keyword:

class Job {
  title: string
  constructor(title: string) {
    this.title = title
  }
  display() {
    console.log(this.title)
  }
}

const admin = new Job('admin')
const receptionist = new Job('receptionist')

Now, because we're creating any number of Job objects we can't hard code a reference to it in the display method, simply because we don't know about it yet. Instead we use this as a way of saying "the object I belong to at creation". In fact, we can see two references of this here:

  1. In the constructor we are saying "when creating the object with new, add the title argument to that particular instance"
  2. When display is called, log the title assigned to that same instance

This becomes even clearer when we take a look at the resulting objects in the console:

Here you can see that the display method is 'attached' to both instances, and the this keyword allows it to work this way. In fact, methods created on the prototype are shared across instances which we can prove with a simple comparison:

// logs: true
console.log(receptionist.display === admin.display)

The first guideline

At this point we can create our first guideline:

The object before the . is what this will refer to when the function is called

Following on from the above if I were to call receptionist.display() then I can assume that the this value in display will indeed be the receptionist.

What is interesting though, is that this is decided when the function is called, not when it is defined.

One way to think about it is that this behaves similar to a parameter in a function, but it is added behind the scenes along with any other explicit parameters that we define. For example:

receptionist.display(receptionist)

// not valid code
display(this) {
  console.log(this.title)
}

We will see that TypeScript thinks this way as well.

So, can you lose the this reference? Consider this example:

const adminDisplay = admin.display

adminDisplay()
// Error: can't access property "title", this is undefined

By assigning the admin.display function to a separate variable we've now bypassed the first guideline and there is no object proceeding the . anymore, which means at runtime this does not point to anything. It's as if we've called our function without a this parameter.

When this happens it will default to undefined and we see the error in the console.

Luckily we have ways to assign whatever value we want to this to work round such issues, but first let's take a step back and look at how TypeScript works with this

Introducing this to TypeScript

Remember when I said earlier that you can think of this like a hidden function parameter? With TypeScript it's as if we reveal that parameter and make it very clear what to expect.

We can revisit our Job example to demonstrate:

class Job {
  title: string
  constructor(title: string) {
    this.title = title
  }
  display() {
    console.log(this.title)
  }
}

In this example, we don't need to tell TypeScript anything, as it can infer what the value of this is correctly because it's clear that display belongs to instances of Job. Nice to see it following our guideline!

Next let's intentionally create a function that has no connection to Job:

function jobDisplay(this: Job, message: string) {
  console.log(`${this.title} ${message}`)
}

The jobDisplay function is standalone, but it also expects to be able to reference this.title which is found on instances of Job.

By using a special TS specific annotation we can tell it what to expect this to be with the first parameter, then any further parameters are the actual arguments to the function.

We can try to use it:

function jobDisplayWithMessage(this: Job, message: string) {
  console.log(`${this.title} ${message}`)
}

jobDisplayWithMessage('is a good job')

TypeScript will now correctly warn us that this function was expecting this to reference an instance of Job, but it's undefined here. This is much better than the runtime error we had earlier.

Can this be fixed?

JS has a way to tell a function what this should be, using call, apply or bind. We can fix this particular example with call:

const admin = new Job('admin')

function jobDisplayWithMessage(this: Job, message: string) {
  console.log(`${this.title} ${message}`)
}

// logs: admin is a good job
jobDisplayWithMessage.call(admin, 'is a good job')

Overriding this with call or apply

Now seems a good time to take a closer look at two functions to allow changing the value of this when a function is called.

call

As we saw above, call behaves just like calling a function but the first argument is used to set the this context. All additional arguments are passed one at a time:

someFunction.call(object, 'argument1', 'argument2')

If you need to pass arguments to it dynamically then the spread operator can be used:

someFunction.call(object, ...someValues)

The same technique can be used to pass the arguments from a function directly:

// using the `arguments` variable
// in a function declaration
function example() {
  return someFunction.call(object, ...arguments)
}

// if it's an arrow function then we don't
// have `arguments` available
const example = (...args: unknown[]) => {
  return someFunction.call(object, ...args)
}

Not being able to use arguments inside arrow functions is one reason you don't often see it being used. Another limitation is that it is 'array-like', meaning it lacks things like map and forEach.

These days there is no reason not to favour rest parameters instead.

apply

This behaves exactly the same as call, but accepts an array as the second argument instead. Consider the two examples above, but using apply:

function example() {
  return someFunction.apply(object, arguments)
}

// if it's an arrow function then we don't
// have `arguments` available
const example = (...args: unknown[]) => {
  return someFunction.apply(object, args)
}

There isn't much difference between the two since the spread operator became more widely supported, but I still favour using apply with an array to make things more obvious at a glance.

Creating bound functions

The final part of this journey is to explore the creation of bound functions, which will reveal the use case for the bind function.

We can start with a fresh example, a function that will display a greeting to a User:

type User = {
  firstName: string
  lastName: string
}

function greetUser(this: User, greeting: string, message: string) {
  return `${greeting}, ${this.firstName} ${this.lastName}. ${message}`
}

And it can be used like this:

const john = {
  firstName: 'John',
  lastName: 'Smith',
}

// log: Hey, John Smith. Good to see you
greetUser.call(john, 'Hey', 'Good to see you')

Now imagine that we always wanted to greet John, instead of using call each time and passing a reference to the john object we could create a new function that is bound to it for every invocation:

function greetJohn(greeting: string, message: string) {
  return greetUser.call(john, greeting, message)
}

// log: Hey, John Smith. Good to see you
greetJohn('Hey', 'Good to see you')

We can remove the boilerplate from greetJohn by using bind instead:

const greetJohn = greetUser.bind(john)

This behaves exactly like our version that uses call. This is a good way to think of bind whenever you see it. A new function is returned that calls the original one with a fixed value for the this keyword.

And this cannot be changed, just like you can't override the use of call inside the first greetJohn:

const greetJohn = greetUser.bind(john)

// has no effect, and will log:
// "Hey, John Smith. Good to see you"
greetJohn.call(someOtherUser, 'Hey', 'Good to see you')

A more concrete example of bind

Now that we have some understanding of what bind can do, what would be a realistic application of it? One good example relates to event handlers.

When attaching a handler function to an element using addEventListener(), the value of this inside the handler will be a reference to the element. It will be the same as the value of the currentTarget property of the event argument that is passed to the handler.

-- MDN

Consider this example, where we are creating a class that attaches a method as a click handler:

class ElementClick {
  element: HTMLElement | null
  message: string

  constructor(selector: string) {
    this.message = 'hello'
    this.element = document.querySelector(selector)
    // here we pass a reference to `handleElementClick`
    this.element?.addEventListener('click', this.handleElementClick)
  }

  handleElementClick(event: MouseEvent) {
    // this will log `undefined`
    console.log(this.message)
  }
}

new ElementClick('#button')

What we would actually like to happen here is that handleElementClick is able to reference our ElementClick instance and log 'hello' to the console.

By default we can imagine that somewhere deep down in native JavaScript the callback we pass to addEventListener is called with the this value as the target element.

Instead, if we bind that handler it will work as expected:

constructor(selector: string) {
  this.message = 'hello'
  this.element = document.querySelector(selector)
  this.element?.addEventListener('click', this.handleElementClick.bind(this))
  // -------------------------------------------------------------- ^
}

handleElementClick(event: MouseEvent) {
  // this will log 'hello'
  console.log(this.message)
}

A common way to handle this problem is to use an arrow function for event handlers (and other similar callbacks). They do not have a this binding and instead take the value from their parent scope.

MDN Arrow Functions

By changing handleElementClick to an arrow function can achieve the same result as bind:

constructor(selector: string) {
  this.message = 'hello'
  this.element = document.querySelector(selector)
  this.element?.addEventListener('click', this.handleElementClick)
}

handleElementClick = (event: MouseEvent) => {
  console.log(this.message)
}

Partial application

Now that we have solidified an understanding of bind and how it can be used, there is one more useful feature to look at, known as partial application. This is when a new function is created that already has some of its arguments pre-filled.

Let's take a step back to John and his greetings. If you can recall, we had created a greetJohn function that was bound to the john object:

const john = {
  firstName: 'John',
  lastName: 'Smith',
}

function greetJohn(greeting: string, message: string) {
  return greetUser.call(john, greeting, message)
}

// usage
greetJohn('Hey', 'Good to see you')

What if we were to wrap greetJohn with another function, and this time bind one if its arguments instead of just the this value?

function welcomeJohn(message: string) {
  return greetJohn('Welcome', message)
}

// log: 'Welcome, John Smith. How was your weekend?'
welcomeJohn('How was your weekend?')

Now whenever we call welcomeJohn it will always force greetJohn to accept 'Welcome' as its first argument. We can take this a step further still and create another function that binds the message argument as well.

Let's insult poor John:

function welcomeJohn(message: string) {
  return greetJohn('Welcome', message)
}

function greetJohnAndInsult() {
  return welcomeJohn('You stink!')
}

// log: Welcome, John Smith. You stink!
greetJohnAndInsult()

Using bind for easy partial application

We saw earlier that bind removes the boilerplate of binding the this value. Another thing it enables are additional arguments to be passed and this will also be bound to your function for all future invocations. We can rewrite our examples above:

const john = {
  firstName: 'John',
  lastName: 'Smith',
}

const greetJohn = greetUser.bind(john)
const welcomeJohn = greetJohn.bind(null, 'Welcome')
const greetJohnAndInsult = welcomeJohn.bind(null, 'You stink!')

Notice the use of null there when calling bind. This is a common pattern when you want to ignore the this value and just set the bound arguments. Recall earlier how we saw that you cannot override the value of this once it is applied by bind.

In the above example that value has already been bound with greetJohn, so to avoid confusion when creating welcomeJohn and greetJohnAndInsult we can just use null.

A more realistic example might be binding values to an event handler in a component library like React:

const handleClick = (action: string, event: MouseEvent) => {
  event.preventDefault()
  sendToAnalyticsPlatform(action)
}

function Component() {
  return (
    <button 
      onClick={handleClick.bind(null, 'add_user')}>
      Add user
    </button>
  )
}

Here we're binding the event action 'add_user' into the click handler and you will notice that it is placed before the event argument, which would usually be the first and only argument.

However, it's far more common to just pass an anonymous function to onClick that will then call sendToAnalyticsPlatform when invoked. I definitely prefer to keep my event handling separate from the actual business logic that is triggered:

<button
  onClick={(event) => {
    event.preventDefault()
    sendToAnalyticsPlaform('add_click')
  }}
>
  Add user
</button>

But this was a useful exercise to get a good understanding of bind and its uses.

Resources

Comments