npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

deep-enum-core

v0.0.3

Published

Make deeply nested enums out of any constant object with a full type-safe interface.

Downloads

7

Readme

deep-enum-core NPM version NPM monthly downloads coverage License

Make a deeply nested enum of constant values that provides type-safety for those constants

OR, make a deeply nested enum from an object's interface to provide full type-safety for getting/setting those nested properties on that interface

Installation

npm install deep-enum-core

Usage

There are two primary use cases for this library:

Deep-Enum Constants

A deep-enum constant is a constant, readonly object that is used to semantically group constants together in a nested fashion (like a regular enum, but nested). The main use case for this is, like with normal enums, 1) re-use and 2) type-safety.

Here is an example of creating a deep-enum constant for storing specifc values (i.e. the value of the enum needs to be a specific string or number):

// (excluding import statements)
// DirectionsEnum.ts
export const DirectionsEnum = deepEnumConstant({
  Cardinal: {
    N: 'north',
    E: 'east',
    S: 'south',
    W: 'west',
  },
  Ordinal: {
    NE: 'northeast',
    SW: 'southwest',
    SE: 'southeast',
    NW: 'northwest',
  },
} as const);

export type DirectionsType = DeepEnumConstantType<typeof DirectionsEnum>;

// move.ts
function move(direction: DirectionsType) {
    return `You are now moving ${direction}`;
}

// move-usage.ts
move(DirectionsEnum.Cardinal.N);  // ✅ You are now moving north
move(DirectionsEnum.Cardinal.SE); // ✅ You are now moving southeast
move('invalid');                  // ❌ Argument of type '"invalid"' is not assignable to parameter of type '"north" | "east" | "south" | "west" | "northeast" | "southwest" | "southeast" | "northwest"'.

NOTE: the object must have the TypeScript const keyword to indicate that the property values are string literals and not just strings (read more why in TS docs).

But sometimes, you don't really need to store a specific value for an enum, you just want the type-safety and explicit nature of the enum. In these cases, you can create a deep-enum constant that ignores the enum values, like so:

// AnimalEnum.ts
const Animal = {
  Bird: {
    Parrot: 0,
    Penguin: 0,
  },
  Mammal: {
    Dog: 0,
    Cat: 0,
  },
};

// NOTE: the values are "ignored" and "don't matter" in this use-case (by choice) because the enum object
// generates new values to represent each constant. If you *just want type-safety* for deeply nested enum constants, 
// you shouldn't have to worry about the values. See the previous section if you do care about the values.
export const AnimalEnum = createDeepEnumInterface(Animal);
export type AnimalType = DeepEnumType<typeof AnimalEnum>;

// move.ts
function move(animal: AnimalType) {
  if (animal === AnimalEnum.Mammal.Dog) {
    // ...
  }
}

// move-usage.ts
move(AnimalEnum.Mammal.Dog); // ✅
move(0);                      // ❌ Argument of type '0' is not assignable to parameter of type '"Bird.Parrot" | "Bird.Penguin" | "Mammal.Dog" | "Mammal.Cat"'

See the API section for more details on how the interfaces work.

Deep-Enum Interface

A deep-enum interface will allow you to get/set (read/write) to deeply nested properties of that object using the enum as the interface (abstract from the object that is being changed). All you need is the deep-enum interface to specify which property/path you want to update on a given object of that same interface.

// a TypeScript interface that is static that we want to make a deep-enum interace out of to update its nested properties
type UserForm = {
  user: {
    name: string;
    address: {
      line1: string;
    };
  };
};

// an object that we want to be able to update using the above interface
const userForm: UserForm = {
  user: {
    name: '',
    address: {
      line1: '',
    },
  },
};

// create a deep-enum object that can be used to type-safely reference paths of the above object
const USER_FORM_ENUM = createDeepEnumInterface(userForm);

// immutable object updates
const newUserForm = set(userForm, USER_FORM_ENUM.user.address.line1, '123 Main St immutable');
get(userFormObject, USER_FORM_ENUM.user.address.line1); // '123 Main St immutable'

// mutable object updates
setMutable(userForm, USER_FORM_ENUM.user.address.line1, '123 Main St mutable');
get(userForm, USER_FORM_ENUM.user.address.line1); // '123 Main St mutable'

Continue onto the API to see the full list of the helper functions you can use to avoid some of this boiler plate and get re-use when doing get/set on an object using a deep-enum.

API

Terms:

  • enum: a group of related constants that share sematic meaning. its values are used to enforce more strict type-safety on variables and paramters
    • TypeScript docs define enums to "allow a developer to define a set of named constants. Using enums can make it easier to document intent, or create a set of distinct cases."
  • deep-enum: like an enum, but the constants are nested
  • path or property: a set of object keys that uniquely define a value (either another object or primitive value) within an object
    • e.g., const obj = {a: {b: {c: 'value'}}}; has 3 paths/properties: 'a', 'a.b', and 'a.b.c'
  • deep-enum path: a path where the value is a primitive (not an object)
    • e.g., const obj = {a: {b: {c: 'value'}}}; has 1 deep-enum path 'a.b.c'
  • deep-enum value: a deep-enum path's value
    • e.g., const obj = {a: {b: {c: 'value'}}}; has 1 leaf value: 'value'

createDeepEnumInterface(obj, postfixIdentifier)

  • Generates a deep-enum interface object from a regular object that can be used as an enum accessor to an object with the same interface.

  • NOTE: this object is immutable, readonly, and can't be changed, simply because it is an enum!

  • Params

    • obj: the object to generate the deep-enum from, must be a plain object or record or key-value pair object

    • postfixIdentifier (optional): the value to append to the end of a path when generating the enum values

      • Use this to detect if you are properly using the deep enum object interface and not hard-coding the string literals anywhere, e.g.
          
  • Returns

    • the deep-enum object which holds the paths that can be used to index into the same interface

Motivation

The main reason to use enums is to have stricter type-safety for a set of related constants, while also conveying the semantics of those constants.

Let's take a look at standard TypeScript enums:

enum Direction {
  Up,
  Down,
  Left,
  Right,
}

function move(direction: Direction) {
  // ...
}

This Direction enum defines 4 constants (internally represented as 0, 1, 2, and 3) that relate to eachother, and we can see that being used semantically in a function move that takes in a Direction parameter.

This is a great interface because we can access the semantically meaningful constant without worrying about what that constant actually is at runtime, allowing us to simply do move(Direction.Up) in code, and avoid doing things like move(0).

...but, what if your constants are hierarchical or deeply nested?

Deep-Enums

A deep-enum is an object that defines constants in a semantically meaningful way that provides type-safety, like a regular TypeScript enum, while also allowing said constants to be deeply nested, which is something you cannot do with a regular TypeScript enum.

In an ideal world, if this was native to TypeScript, it could look something like this:

enum Animal {
  Bird: {
    Parrot,
    Penguin
  },
  Mammal: {
    Dog,
    Cat
  },
}

However, since this is not the case, we have to use standard TypeScript object interfaces to define a deep-enum like this one:

const Animal = {
  Bird: {
    Parrot: 'Bird.Parrot',
    Penguin: 'Bird.Penguin',
  },
  Mammal: {
    Dog: 'Mammal.Dog',
    Cat: 'Mammal.Cat',
  },
};

// or

const Animal = {
  Bird: {
    Parrot: 1,
    Penguin: 2,
  },
  Mammal: {
    Dog: 3,
    Cat: 4,
  },
};

Obviously this is less-than-ideal than the standard enum interface where values are implicitly defined, but because we are defining a static object literal, we must specify the property values of each enum value, making it much more verbose. We know the whole point of an enum is that the values should not matter, they just need to be internally unique. But, we need to assign the properties to something just because we need to create a valid object literal.

With standard enums, the object was flat, so you could always access every enum property simply using the dot or property accessor operator. And deep-enums are no different, we can still simply do Animal.Bird.Parrot and get an enum value.

Now that we have an object, how do we actually use it to enforce type-safety?

All we need to do is derive a few TypeScript helper interfaces and some helper functions and we can realize the full power of deep-enums.

Deriving Type-Safe Interfaces

Let's say we want a function with this interface, using the above Animal object

function move(animal: Animal) {
  // ...
}

This exact interface doesn't work for a few reasons

  • Animal is a value and can't be used as a type
  • typeof Animal won't work either, because that's just the type of the Animal object as a whole, not the individual enums (animal === Animal.Bird.Parrot breaks)
  • keyof typeof Animal won't work because that only gives us top-level keys, and not the nested enums.

What we really want is all valid enum keys as a type itself. So we could do something like this


type AnimalType = typeof Animal.Bird.Parrot |  typeof Animal.Bird.Penguin | typeof Animal.Mammal.Dog | typeof Animal.Mammal.Cat;
function move(animal: AnimalType) {
  if (animal === Animal.Bird.Parrot) {  
    // ...
  }
}

This works. But clearly, the second you start adding more to your enum, you have the manually duplicate type information which is an anti-pattern and should be avoided.

Luckily, since TypeScript 4.1 when recursive conditional types and template literal types were introduced, we can use TypeScript to derive a type-safe interface for Animal. This library derives the type-safe interface of your object for you so we get both simplicitly and verbose type-safety at the same time.

See the Deep-Enum Constants section for this final code usage showing the move function with type-safety.

The Trick

Quite simply, this library just converts a normal JavaScript object to a constant JavaScript object whose properties are replaced with each property's path as its value. So when you refer to an enum's nested property, you're actually just refferring to a string representation of the path of that property.

const obj = {a: {b: {c: 'value'}}};
const MY_ENUM = createDeepEnumInterface(obj);
console.log(MY_ENUM.a.b.c); // 'a.b.c'

There's really no magic to the enum object itself, but the value comes in when you use the enum object with the type-safe helper functions of this library for getting/setting deeply nested values in an object using the enum as an interface. This allows you to further decouple "how" objects are updated, and all you need is the "path" and "value" of a property to update.

Limitations

Doesn't operate on arrays

Benchmarks

Related