DEV Community

Xavier Haniquaut
Xavier Haniquaut

Posted on • Edited on

Enforce uniqueness with Symbols

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

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

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

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 to false

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

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

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

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

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

Top comments (0)