DEV Community

Old Starchy
Old Starchy

Posted on

private vs #private

Preamble (aka you can skip this part)

A few weeks ago I got distracted at work and accidentally wrote this blog as a src/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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
    };
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. If it is a bound method, use ES private
  2. If it is a property backing field, use ES private
  3. 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();
Enter fullscreen mode Exit fullscreen mode

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}"
Enter fullscreen mode Exit fullscreen mode

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`
Enter fullscreen mode Exit fullscreen mode

This works for any kind of "left of the dot".

something().list[4].printThis();
Enter fullscreen mode Exit fullscreen mode

becomes

const _ = something().list[4];
_.printThis(_);
Enter fullscreen mode Exit fullscreen mode

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 {...}"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Exceptions: Arrow functions

An arrow function (aka lambda expression) uses the arrow => token to create a small anonymous function.

const printThis = () => console.log(this);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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`);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Use a newly created .bind method

    document.body.addEventListener('click', this.logClick.bind(this));
    

    This is kinda wordy but it works.

  2. 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.

  3. 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.

  4. 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() {
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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 refs 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`;
    }
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)