Preamble (aka you can skip this part)
A few weeks ago I got distracted at work and accidentally wrote this blog as asrc/readme.md
. Now that I'm up to merging that branch in its time to remove that readme, but I like to think that more than just the 4 other people in my team might find this perspective interesting. So now I present it to you all as-is. Happy coding!
Hello
I decided to take note of a convention that I've been using in this project regarding TypeScript style private and ECMAScript style private.
class Foo {
private bar: string;
constructor() {
this.bar = 'secret sauce';
}
}
console.log(new Foo().bar); // TypeScript error
In TypeScript you can use the private
keyword to mark fields, properties, and methods as "private" (accessable only from within the same class).
Once this code gets compiled however, this restriction no longer holds because private
is not part of JavaScript.
Depending on your TypeScript configuration the compiler will both complain about the misuse of the private field but still compile and emit code as below.
class Foo {
constructor() {
this.bar = 'secret sauce';
}
}
console.log(new Foo().bar); // logs "secret sauce"
The alternative option is to use ECMAScript's built in "private" modifier which is done by prefixing the name with a hash #
.
class Foo {
#bar: string;
constructor() {
this.#bar = 'secret sauce';
}
}
console.log(new Foo().#bar); // TypeScript error
This style is a "real private field" in the sense that privacy will be inforced by the JavaScript engine at runtime.
class Foo {
constructor() {
this.#bar = 'secret sauce';
}
}
console.log(new Foo().#bar); // JavaScript error
In our project configuration errornously using either will result in a compile error and an angry red line in VSCode.
This raises the question of which one should we use? If both work and as far as we're concerned do the same thing it shouldn't matter; it (mostly) comes down to a matter of personal preference.
Throughout the development of this project (and other personal projects) I've settled on the following convention;
class ClickLogger {
#clickCount: number;
get clickCount(): number {
return this.#clickCount;
}
constructor() {
this.#clickCount = 0;
document.body.addEventListener('click', this.#handleClick);
}
private logClickAtLocation(x: number, y: number): void {
this.#clickCount++;
console.log(
`${nth(this.clickCount)} click detected! Clicked at ${x}, ${y}`,
);
}
#handleClick = (e: MouseEvent) => {
this.logClickAtLocation(e.clientX, e.clientY);
};
}
NOTE: The implementation of
nth
is not important for the example but I've included it below anyway.
The convention can be defined as follows:
- If it is a bound method, use ES private
- If it is a property backing field, use ES private
- otherwise use TypeScript private
If it is a bound method, use ES private
The value returned by JavaScript's this
keyword is not always known at the time of writing a function or method.
This is because the value is only set when the function or method is invoked.
The best way to think of this
is as a hidden parameter to every function, and JavaScript will decide what value to place there depending on how you invoke it.
So when you write code that looks like this
function printThis() {
console.log(this);
}
const obj = {
foo: 'bar',
printThis,
};
printThis();
obj.printThis();
What you're getting is code that runs like this*
NOTE: This assumes that your TypeScript compiler is using "strict mode" (more on that later)
function printThis(this: unknown) {
console.log(this);
}
const obj = {
foo: 'bar',
printThis,
};
printThis(undefined); // logs "undefined"
obj.printThis(obj); // logs "{foo: 'bar', printThis: f}"
Generally speaking, the value used for this
can be found by looking left of the dot. If there is no dot, undefined
is used.
printThis();
\- there's no dot, so `undefined` is used
obj.printThis()
|-|
- Left of the dot is "obj" so "obj" is passed as `this`
This works for any kind of "left of the dot".
something().list[4].printThis();
becomes
const _ = something().list[4];
_.printThis(_);
NOTE:
something().list[4]
is only evaluated once.
Exceptions: 'use strict':
I mentioned above about assuming "strict mode". JavaScript's strict mode changes the behavior slightly. In the case that there is no "left of the dot" JavaScript actually uses the value globalThis
, which in browsers is a reference to the global Window
object. Only in strict mode does it use undefined
. It is good practice to always use strict mode as it tweaks a number of things to make the runtime behavior more predictable and (therefore) safer, but to be complete, here's an example:
function printStrict() {
'use strict';
console.log(this);
}
function printLoose() {
console.log(this);
}
printStrict(); // logs "undefined"
printLoose(); // logs "Window {...}"
All the code in this readme assumes strict mode is enabled.
Exceptions: Bound methods
Its also possible to preload a function with a value for this
(think function currying) using the .bind
method.
function printThis() {
console.log(this);
}
const message = 'Abra kadabra';
const boundPrint = printThis.bind(message);
printThis(); // undefined
boundPrint(); // "Abra kadabra"
Exceptions: Arrow functions
An arrow function (aka lambda expression) uses the arrow =>
token to create a small anonymous function.
const printThis = () => console.log(this);
The value of the this
keyword inside an arrow function is set at the moment the function is created. In other words, it is roughly equivalent to the following:
const printThis = function () {
return console.log(this);
}.bind(this);
The value of this
inside the function is inherited from whatever it was when the function was created.
Now that we know how this
works (right?) I can get to the point... almost.
(most) Callback functions are called in a way that would assign undefined
to this
. This means that the below code will not work
class ClickLogger {
private clicks = 0;
constructor() {
document.body.addEventListener('click', this.logClick);
}
private logClick() {
this.clicks++;
console.log(`Clicked ${this.clicks} times`);
}
}
When the event emitter invokes our logClick
method it does so like this
class EventEmitter {
// ...
emitEvent(e) {
for (const handler of this.handlers) {
handler(e);
}
}
}
So when we run this.clicks++
we get an error because this
has a value of undefined
.
To fix this we need to create a bound method. There's three ways to do this:
-
Use a newly created
.bind
method
document.body.addEventListener('click', this.logClick.bind(this));
This is kinda wordy but it works.
-
Reuse an existing
.bind
method
class ClickLogger { constructor() { document.body.addEventListener('click', this.logClick); } private logClick = function () { // ... }.bind(this); }
This is even more wordy, but it has the benefit of being able to call
document.body.removeEventListener('click', this.logClick)
later. -
Use a new arrow function
document.body.addEventListener('click', (e) => this.logClick(e));
The shortest so far but suffers from not being able to
.removeEventListener
as in example 1. -
Use an existing arrow function
class ClickLogger { constructor() { document.body.addEventListener('click', this.logClick); } private logClick = () => { //... }; }
IMO this one is the neatest. Its not that much longer than a regular method, it captures the value of
this
due to being defined as an arrow function, and it can be used in.removeEventListener
.
Now I have decided I like the 4th option above, but I have one more gripe; It's not easy to tell which methods are bound and which are not:
class ClickLogger {
constructor() {
document.body.addEventListener('click', this.logClick);
document.body.addEventListener('keydown', this.resetCount);
}
// Somewhere else in the file possibly off screen
private logClick = () => {
// ...
};
private resetCount() {
// ...
}
}
resetCount
isn't bound; oops, now your code doesn't work.
So for bound methods I like to use ES private #
for and unbound methods I use TypeScript private. This is a form of Hungarian Notation.
If it is a property backing field, use ES private
class ClickCounter {
#clickCount: number;
get clickCount() {
return this.#clickCount;
}
}
Both #clickCount
and clickCount
represent the same data, but we can't give them both the same name with TypeScript private because when its compiled private
goes away and it ends up being the same property twice.
class ClickCounter {
private clickCount: number; // error: duplicate identifier
get clickCount() {
return this.clickCount;
} // error: duplicate identifier
}
So if we must pick a slightly different new name, why not pick the one that aligns with how JavaScript itself works.
otherwise use TypeScript private
That concludes my essay. Thank you for your time.
P.S.
I nearly forgot to mention one difference between private
and #
when using Vue.
class Counter {
#count: number;
get count() {
return this.#count;
}
increment() {
this.#count++;
}
}
const c = ref(new Counter());
c.value.increment(); // ERROR, cannot access private variable #count from outside the class it was defined in
Refs in Vue wrap their values in a Proxy
object, so you get looks something like this
const c = {
value: new Proxy(new Counter(), {
get: (target, key) => {
return target[key];
},
}),
};
c.value.increment();
Now the value "left of the dot" is actually the Proxy
object, not the Counter
. The increment
function is then called on the Proxy
and since the #count
is private to the Counter
it becomes an error to try access it. shallowRef
also has this problem. You can decide for yourself if you think that Proxies
are an anti-pattern.
To get around this you can useRaw(c.value).increment()
but this is ugly and given that ref
s are used everywhere, it would become tedius and noisy.
You could also use private
because in JavaScript Land private
isn't really "private".
The End.
function nth(n: number) {
const str = n.toFixed(0);
if (str.slice(-2, -1) === '1') return `${str}th`;
switch (n % 10) {
case 1:
return `${str}st`;
case 2:
return `${str}nd`;
case 3:
return `${str}rd`;
default:
return `${str}th`;
}
}
Top comments (0)