The following is a somewhat loose interpretation of a "friend class"-like feature exposed through decorators:
NOTE: this has been adapted from https://github.com/tc39/proposal-unified-class-features/blob/master/friend.js
// friend.js
export class FriendKey {
#names = new Map();
#ignored = new Set();
#storageKeys = new Set();
get(object, name) {
const storageName = this.#names.get(name);
if (storageName === undefined) throw new TypeError();
return storageName.get(object);
}
set(object, name, value) {
const storageName = this.#names.get(name);
if (storageName === undefined) throw new TypeError();
storageName.set(object, value);
}
invoke(object, name, ...args) {
const storageName = this.#names.get(name);
if (storageName === undefined) throw new TypeError();
const func = storageName.get(object);
return Reflect.apply(func, object, args);
}
get expose() {
return descriptor => descriptor.kind === "class"
? this.#exposeClass(descriptor)
: this.#exposeMember(descriptor);
}
get ignore() {
return descriptor => this.#ignoreMember(descriptor);
}
#exposeClass(descriptor) {
if (descriptor.kind !== "class") throw new TypeError();
let extras = [];
const elements = descriptor.elements.map(member => {
// members whose names are not private cannot be exposed.
if (typeof member.key !== "privatename") return member;
// storage members cannot be exposed.
if (this.#storageKeys.has(member.key)) return member;
// members that were ignored cannot be exposed.
if (this.#ignored.has(member.key)) return member;
// members that were already exposed cannot be re-exposed.
if (this.#names.has(member.key.toString())) return member;
const exposed = this.#exposeMember(member);
extras = extras.concat(exposed.extras);
return {
key: exposed.key,
placement: exposed.placement,
kind: exposed.kind,
descriptor: exposed.descriptor
};
});
descriptor.elements = elements.concat(extras);
return descriptor;
}
#exposeMember(member) {
if (typeof member.key !== "privatename") throw new TypeError();
if (member.kind !== "method" && member.kind !== "field") throw new TypeError();
const key = member.key;
const keyString = key.toString();
if (this.#ignored.has(key)) throw new TypeError("A member cannot be both exposed and ignored.");
if (this.#names.has(keyString)) throw new TypeError("A member with the same name has already been exposed.");
if (this.#storageKeys.has(key)) throw new TypeError("This member cannot be ignored or exposed.");
// create a new private name for the member.
const storageName = new PrivateName();
const storageKey = storageName.key;
// map the string name of the member's key to the new private name.
this.#names.set(keyString, storageName);
// track the new storage key to prevent @expose on a class from wrapping the member again.
this.#storageKeys.add(storageKey);
// replace the member with one that acts as a proxy for the actual member.
return {
key,
placement: member.placement,
kind: "method",
descriptor: {
get: function() { return storageName.get(this); },
set: function(value) { storageName.set(this, value); }
},
extras: [{
key: storageName.key,
placement: member.placement,
kind: member.kind,
descriptor: member.descriptor;
}];
};
}
#ignoreMember(member) {
if (typeof member.key !== "privatename") throw new TypeError();
if (member.kind !== "method" && member.kind !== "field") throw new TypeError();
const key = member.key;
const keyString = key.toString();
if (this.#names.has(keyString)) throw new TypeError("A member cannot be both ignored and exposed.");
if (this.#ignored.has(key)) throw new TypeError("A member with the same name has already been ignored.");
if (this.#storageKeys.has(key)) throw new TypeError("This member cannot be ignored or exposed.");
this.#ignored.add(member.key);
return member;
}
}Friendship can then be established by creating a single shared key that can be used to expose private state to those with access to the key:
// a.js
import { FriendKey } from "./friend.js";
const aFriend = new FriendKey();
export class A {
@aFriend.expose
#x;
@aFriend.expose
#y() { return this.#x; }
constructor(x) { this.#x = x; }
}
export class B {
getX(aInstance) {
return aFriend.get(aInstance, "#x");
}
setX(aInstance, value) {
aFriend.set(aInstance, "#x", value);
}
callY(aInstance) {
return aFriend.invoke(aInstance, "#y");
}
}This approach, however, is only safe if both the "friend class" and the class it is
friends with are both defined in the same file. If the FriendKey object used to establish friendship
escapes the current module (e.g. via an export), then anyone would be able to use it to access
private state.
This approach could be modified to improve its hard privacy aspects, after a fashion:
// a.js
import { FriendKey } from "./friend.js";
const aFriend = new FriendKey();
let hasFriend = false;
export class A {
@aFriend.expose
#x;
@aFriend.expose
#y() { return this.#x; }
constructor(x) { this.#x = x; }
}
export function getAFriendship() {
if (hasFriend) throw new Error("Friendship has already been established");
hasFriend = true;
return aFriend;
}
// b.js
import { getAFriendship } from "./a.js";
const aFriend = getAFriendship();
export class B {
getX(aInstance) {
return aFriend.get(aInstance, "#x");
}
setX(aInstance, value) {
aFriend.set(aInstance, "#x", value);
}
callY(aInstance) {
return aFriend.invoke(aInstance, "#y");
}
}This approach is slightly better, in that aFriend can only be accessed once outside of its module.
A malicious user could still access aFriend before B, however this would result in an error
immediately before B is declared.