(Note for readers: this is one of my first, if some sentences need some fixes, do not hesitate to tell me in comments.)
For some reasons you may want to enforce a unique token to access something hidden in a more complex structure.
There is some uses case such as forcing the consumer to use a service like intended. Or force a consumer to use a given method with only chosen strings.
We can often see the following pattern: storing strings in an object and use the property as reference to this string to enforce the unicity of that string through the codebase.
// definitions
export default const AVAILABLE_FF = {
ff1: 'feature_flag_1',
ff2: 'feature_flag_2',
};
// in a component controller
import { ff1 } from './definitions'
const hasAccessToOne = ffService.hasAccess(ff1)
// in another component controller
import { ff1 } from './definitions'
const hasAccessToOne = ffService.hasAccess(ff1)
This approach is easy to implement and enables us to have our string only in one place, but it has down sides: it allows people to be lazy and forge their own key if they want to (or worse!).
// in another component controller
const hasAccessToOne = ffService.hasAccess('feature_flag_1') //<-- forgery
This is possible and tolerated by the system and could cause bad behaviours on the long run, such as forging the string.
// bad behaviour resulting of a weak system allowing it
const FeatFlagPrefix = 'feature_flag_';
const [
hasAccessToOne,
hasAccessToTwo
] = [1,2].map(featName => ffService.hasAccess(`${FeatFlagPrefix}${featName}`));
Symbols
Symbol is a kind of primitive that is used to return unique symbol.
- create a new symbol :
const iAmUnique = Symbol()
- symbols are really unique so :
Symbol() === Symbol()
will evaluate tofalse
Their unique nature make them a perfect tool to enforce uniqueness on a codebase.
Here is an illustrated example to show the power of Symbols:
// here is a classical door implementation
class WeakDoor {
constructor(){
this.normalKey = '🔑key';
}
open(key) {
return (key === this.normalKey)
? console.log('✅you can enter')
: console.log('😡you are blocked');
}
}
// you can use the provided key (normal use)
const door1 = new WeakDoor();
door1.open(door1.normalKey) // open the door
// but you can forge your own key (lazy dev case)
door1.open('🔑key') // open the door
// or use the key of another door! (~~genius~~evil dev case)
const door2 = new WeakDoor();
door1.open(door1.normalKey) // open the door
// here is a strongest implementation leveraging the uniqueness of symbols
class StrongDoor {
constructor(){
this.uniqueKey = Symbol('🔑key'); // text inside is useless (see below)
}
open(key) {
return (key === this.uniqueKey)
? console.log('✅you can enter')
: console.log('😡you are blocked');
}
}
// you can only use the provided key
const door1 = new StrongDoor();
door1.open(door1.uniqueKey) // open the door
// you can not forge your own
door1.open(Symbol('🔑key')) // door is closed
// neither use the key of another door!
const door2 = new StrongDoor();
door1.open(door2.specialkey) // door is closed
The string passed into the Symbol constructor argument is here to ease up the reading, you should only use it for debugging purposes, and never extract it for further use.
Note that JSON.stringify
will not convert a Symbol in string but erase it. JSON.stringify({ a: 1, b: Symbol() })
evaluates to '{"a":1}'
So if you want to use a string afterward, you'll need to have a conversion dictionary.
Refactoring
As an example, here is our first example implemented with Symbols.
// definitions (as symbols)
export const FF1 = Symbol();
export const FF2 = Symbol();
// identifiers
export const FF_IDENTIFIERS = {
[ff1]: 'feature_flag_1',
[ff2]: 'feature_flag_2',
};
// FFService
import FF_IDENTIFIERS from './identifiers'
class FFService {
constructor(profile) { // profile is a dependency
this.profile = profile;
}
hasAccess(ffSym) {
return this.profile.featureflags.find(ffid => ffid === FF_IDENTIFIERS[ffSym])
? true
: false;
}
}
// in a component controller
import { ff1 } from './definitions'
import { FF_IDENTIFIERS } from './identifiers'
const hasAccessToOne = FFService.hasAccess(ff1)
No way of being lazy anymore, you are forced to use the definitions symbols if you want to use the service methods.
Bundling
One way of making things a bit more portable is to bundle everything into the service:
// feature flag service
class FFService {
#profile; // # is for private property
#IDENTIFIERS = {
[FFService.DEFINITIONS.ff1]: 'feature_flag_1',
[FFService.DEFINITIONS.ff2]: 'feature_flag_2',
};
static DEFINITIONS = { // we want all eventual instances to share symbols
ff1: Symbol(),
ff2: Symbol(),
};
constructor(profile) {
this.#profile = profile;
}
hasAccess(ffSym) {
return Boolean(
this.#profile.featureflags.find(ffid => ffid === this.#IDENTIFIERS[ffSym])
);
}
}
Usage:
// coming from API, you should not write that
const me = { name: 'xavier', featureflags: ['feature_flag_2'] };
// app initialisation
const featureFlagService = new FFService(me);
// in components
const { ff1, ff2 } = FFService.DEFINITIONS;
// will return false
const hasAccessToOne = featureFlagService.hasAccess(ff1);
// will return true
const hasAccessToTwo = featureFlagService.hasAccess(ff2);
Bonus
Usage with Map, identifier dictionary is more suited to a Map structure.
On the contrary, symbols refs should be kept in an object structure, it will help destructuring in consumers.
class FFService {
#profile;
#IDENTIFIERS = new Map([
[FFService.DEFINITIONS.ff1, 'feature_flag_1'],
[FFService.DEFINITIONS.ff2, 'feature_flag_2'],
]);
static DEFINITIONS = {
ff1: Symbol(),
ff2: Symbol(),
};
constructor(profile) {
this.#profile = profile;
}
hasAccess(ffSym) {
return Boolean(this.#profile.featureflags
.find(ffid => ffid === this.#IDENTIFIERS.get(ffSym));
);
}
}
Top comments (0)