Working with this in 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:
- In the
constructor
we are saying "when creating the object withnew
, add thetitle
argument to that particular instance" - 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 whatthis
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 ofthis
inside the handler will be a reference to the element. It will be the same as the value of thecurrentTarget
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.
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
- Examples on StackBlitz to try out