"Private class field symbols"

I was wondering if we would get "private symbols" in this thread:

Here's a syntax idea to solve similar problems, but instead of "private symbols", they are "private class field symbol keys" (or similar name). Basically this:

const s = Symbol()

class Foo {
    #[s] = 123

    method() {
        console.log(this#[s])
    }
}

const obj = new Foo()

obj.method() // logs "123"

// Although the symbol is in scope here, the private access with that
// symbol is not allowed here just as regular private field access is also not:
obj#[s] // syntax error, not in an enclosing class

This would then also allow sharing symbols across multiple classes, and there would also be an internal mechanism that checks that access of a particular symbols is ok across all classes that share the symbol (effectively, "protected", or "friend" if the symbol is shared only in a single module and not exported for other people to use when extending):

const s = Symbol() // not exported

export class Foo {
    #[s] = 123

    method() {
        console.log(this#[s])
    }
}

export class Bar extends Foo {
  setValue() {
    // this class can also access the private field
    this#[s] = 456
  }
}

const obj = new Bar()

obj.method() // logs "123"

obj.setValue()

obj.method() // logs "456"

User code:

import {Bar} from 'Bar'

const obj = new Bar()
obj#[...] // syntax error

I feel like this use case is solved for by weak maps

Lets see how you could use this to implement a system of fine-grained inter-class privacy

// module.js

// not exported -- private data is only accessible in this module file
const privateFieldValues = new WeakMap();

export class Class {
  constructor() {
    privateFieldValues.set(this, 456);
  }
}

export class FriendClass {
  constructor(friend /* Class */) {
    console.log(privateFieldValues.get(friend));
  }
}

edit: sorry for all the edits

Related: GitHub - tc39/proposal-private-declarations: A proposal to allow trusted code _outside_ of the class lexical scope to access private state

That's not quite the same with WeakMap because the access (with WeakMap) can happen outside the class body, whereas the # syntax cannot be used outside of the class.

Besides that, the indexed access feels nicer, more readable, and class field syntax is more concise.

Compare this,

const prop = new WeakMap()

class MyClass {
  constructor() {
    prop.set(this, 123)
  }

  method() {
    console.log(prop.get(this))
  }
}

const o = new MyClass()
o.method() // logs "123"
console.log(prop.get(o)) // logs "123"

vs this:

const prop = Symbol()

class MyClass {
  #[prop] = 123

  method() {
    console.log(this#[prop])
  }
}

const o = new MyClass()
o.method() // logs "123"
console.log(o#[prop]) // SyntaxError

The latter allows "protected" to be possible, while still disallowing public usage.

Protected access can only happen with extends, and the access can only happen inside the class definition:

export const prop = Symbol()

export class Base {
  #[prop] = 123
}
import {prop, Base} from './Base.js'

export class Sub extends Base {
  method() {
    console.log(this#[prop])
  }
}
import {prop} from './Base.js'
import {Sub} from './Sub.js'

const o = new Sub()
o.method() // logs "123"
o#[prop] // SyntaxError

// Access by extension (i.e. protected) is fine:
class MyClass extends Sub {
  changeValue() {
    this#[prop] = 456
  }
}

const o2 = new MyClass()
o2.changeValue()
o2.method() // logs "456"
o2#[prop] // SyntaxError

This would be especially useful for real world use cases like Custom Elements with attachInternals that people in the wb dev community want to share to subclasses using a protected-like feature. Some info on the problem:

Basically, currently a base custom element class can do this:

export class BaseElement extends HTMLElement {
  #internals

  constructor() {
    super()
    this.#internals = this.attachInternals()
  }
}

then if the user of the base class wants the internals too, they are out of luck:

import {BaseElement} from 'some-lib'

class SomeElement extends BaseElement {
  #int
  constructor() {
    super()
    this.#int = this.attachInternals() // runtime error! attachInternals was already called!
  }

  method() {
    this.#int // use internals
  }
}

customElements.define('some-el', MyEl) // "finalize" the class (subclasses of SomeElement will need a new name).

The idea is that an end user of some-el should not be able to access the internals. For example, the user cannot do this:

const el = document.querySelector('some-el')
el.#int // SyntaxError (good!)

However, the above example does not work in the first place, because the constructor in the subclass throws. attachInternals can only be called once (as per the current spec at least for now).

The BaseClass would need to find a way to share the internals with subclasses, and its gets hairy.

With the private class field symbol idea, sharing is easy to do without exposing the internals to public users:

const internals = Symbol()

export class BaseElement extends HTMLElement {
  #[internals]

  constructor() {
    super()
    this#[internals] = this.attachInternals()
  }
}
import {internals, BaseElement} from 'some-lib'

class SomeElement extends BaseElement {
  method() {
    this#[internals] // use internals
  }
}

customElements.define('some-el', MyEl) // "finalize" the class (subclasses of SomeElement will need a new name).

With this approach, subclasses can easily use the internals, while public users of the element are still restricted:

import {internals} from 'some-lib'

const el = document.querySelector('some-el')
el.method() // ok
el#[internals] // SyntaxError (good!)

With WeakMap, then the following is possible (and not the desired result):

import {internals} from 'some-lib'

const el = document.querySelector('some-el')
el.method() // ok
internals.get(el) // protected internals are leaked (undesirable)

Once the customElements.define('some-el', SomeElement) call happens, all some-el instances in the DOM will be SomeElement and a user cannot access their internals by class extension. The classes are effectively "finalized".

That's interesting. It doesn't cover whether the private #variable can be exported from a module. If not, then that makes it only usable within a single file. With this "private class field symbols" idea, the symbols can be exported, they're just regular variables, and thus any subclass of a class that uses the symbol will have "protected". Someone who wants to keep a symbol exclusive to a file can enforce "friend" classes from the single file.

If we can export the private #vars then it may be pointless because it becomes public as the syntax is allowed outside of a class:

export private #foo;
import {#foo} from './foo' // requires update to module syntax too

anyObj.#foo // basically just public.

Is this not the same as private symbols? Once exported then their privacy is controlled only by the runtime's module resolution capabilities. Server runtimes like Nodejs support some level of module privacy at the package level. But with custom elements on the web all modules are effectively public urls. Perhaps module privacy could be emulated with a url token that keeps changing?

It is not the same because with Private Symbols, an exported symbol effectively allows "public" access anywhere that the symbol is imported.

However with Private Class Field Symbols, exporting a symbol enables at most "protected" access due to being syntactically limited to lexical class scope (see the above examples where public access causes a SyntaxError).

Thanks, I had forgotten that detail. Limiting their usage to only with a lexical class even though they are defined outside of them seems somewhat arbitrary. Any code that wants to use them can pretend to be in a class:

class _ { static {
  el#[internals]
}}

Maybe it should be only for instance, non-static.

Or another possibility is it should require the object to extend from a class that declared the same private symbol, similar to current private class fields.

In that example, el doesn't extend from _, so it would be a runtime error.

In the meantime, here's a way to do it in a single file, but not possible to split this across files (can't split a class across files):

class Friends {
	#value = undefined

	Foo = class Foo extends Friends {
		constructor() {
			super()
			this.#value = 456
		}

		fooMethod() {
			console.log(this.#value)
		}
	}

	Bar = class Bar extends this.Foo {
		constructor() {
			super()
			this.#value = 789
		}

		barMethod() {
			console.log(this.#value)
		}
	}
}

const {Foo, Bar} = new Friends()

const f = new Foo()
const b = new Bar()

f.fooMethod() // logs "456"
b.barMethod() // logs "789"

@aclaymore Note there how the Foo class has to extend from Friends or it won't work. That's potentially the same limitation that could be applied for private class field symbols (for instances, and for the class constructors).

But this Friends method is perhaps not as nice symbols in a single file because then we can avoid the nesting. Non-private symbol keys can still be enumerated from the outside though. Private class field symbols would not be enumerable, as with regular private fields.

TypeScript cannot currently handle this nested class approach, it has type errors trying to access to outer class scope's private field.

Would that mean that for two classes to be friends they have to inherit from the same parent, which limits where it can be used due to single inheritance.