Typing Object Keys With Enum Values Using Typescript

Volodymyr Yepishev - Nov 1 '23 - - Dev Community

Cover image generated by throwing headline into nightcafe AI, as usual.

Enums provide an excellent way to group constants in Typescript, but how to type objects when there is a need to use them to generate object keys? Today we will explore this.

We will discover how to perform dynamic typing for objects which utilize enum values as keys to point to a given value type. This can prove beneficial in a situation where one has an enum of features and wants to turn it into a dictionary to denote which features are enabled, and which are not, or if an enum of grades needs to be turned into a dictionary to show statistics how many of each grade students scored during a test.

Crafting the functional interface

Foremost, it should be mentioned that there are two ways to create enumerations in Typescript, the first one being using enum keyword and the other one using a constant with as const. The latter has been discussed in this article. The approach we are going to study is applicable to both. To make matters more interesting, let us assume the object manufactured from the enum might have only several of its values, not the whole set.

Therefore, we want to be able to pass an array of enum values and some other arguments to a function, provide a value type for the new mapped object's keys and have the returned type based off this data.

This can be expressed via the following functional interface (fear not, detailed explanation under the code block):

interface  Converter<A  extends  Array<unknown>, V> {
    <T>(keys: Array<T>, ...args: A): Record<string & T, V>
}
Enter fullscreen mode Exit fullscreen mode

The generics here represent the following:

  • A an array of additional values, passed to the converted function, left as an array of unknown, since the interface does not really care about them;
  • V the type for the values, the mapped object keys will be pointing, this will provide the flexibility, we do not impose any restrictions;
  • T this is the enum type, which will be passed.

Simple as that, this is all the heavy lifting that needs to be done, what is left now is to implement the interface for a couple of use cases.

Case 1: Grading students

Consider the following situation: students have written a test and we want to collect data, how many occurrences of each grade there is in the test.

We will be using both types of enumerations for demo purposes:

enum  Grades {
    a = 'A',
    b = 'B',
    c = 'C',
    d = 'D',
    e = 'E',
    f = 'F'
}

const  GRADES = {
    a: 'A',
    b: 'B',
    c: 'C',
    d: 'D',
    e: 'E',
    f: 'F'
} as  const;
Enter fullscreen mode Exit fullscreen mode

The imaginary graded students come from an api and correspond to the following interface:

interface  GradedStudent {
    name: string;
    grade: string;
}
Enter fullscreen mode Exit fullscreen mode

Hence, let us implement grade checker function, we know that one of the extra arguments is going to be the array of graded students and we will be mapping to an integer, since we are counting grades:

const gradeChecker: Converter<[Array<GradedStudent>], number> = (keys, students) => {
    const gradesStats: Record<string, number> = {};
    const keySet = new  Set(keys as  string[]);

    students.forEach(s => {
        const grade = s['grade'];
        if (keySet.has(grade as  string)) {
        gradesStats[grade] = (gradesStats[s['grade']] ?? 0) + 1;
        }
    });

    return gradesStats;
}
Enter fullscreen mode Exit fullscreen mode

As some test data, let us also to generate an array of graded students

const studentGrades: Array<GradedStudent> = Array.from({ length: 20 }, (_, index) => ({
    name: `Student ${index + 1}`,
    grade: (String.fromCharCode(65 + (index % 6)) as  unknown  as  typeof  GRADES)
}) as  unknown  as  GradedStudent);
Enter fullscreen mode Exit fullscreen mode

As such, we can now turn an array of enum values into a dictionary of grade-count by passing an array of them into our gradeChecker function along with the mocked graded students:

// for 'as const', resulting type 'Record<"A" | "B" | "C" | "D" | "E" | "F", number>'
const grades = gradeChecker(Object.values(GRADES), studentGrades);
console.log(grades);
// { "A": 4, "B": 4, "C": 3, "D": 3, "E": 3, "F": 3 }

// for 'enum', resulting type 'Record<Grades, number>'
const grades2 = gradeChecker(Object.values(Grades), studentGrades);
console.log(grades2);
// { "A": 4, "B": 4, "C": 3, "D": 3, "E": 3, "F": 3 }
Enter fullscreen mode Exit fullscreen mode

What really makes this approach interesting, is that the resulting object type is based on the input, so it is dynamic, were we to pass only a single grade type, we would have gotten a different result, i.e. if we tried counting only C grades:

// for 'as const', resulting type 'Record<"C", number>'
const cGrades = gradeChecker([GRADES.c], studentGrades);
console.log(cGrades);
// { "C": 3 }

// for 'enum', resulting type 'Record<Grades.c, number>'
const cGrades2 = gradeChecker<typeof  Grades.c>([Grades.c], studentGrades);
console.log(cGrades2);
// { "C": 3 }
Enter fullscreen mode Exit fullscreen mode

Take note, how as const shines here, providing better typing experience compared to enum, which requires explicit passing of typeof Grades.c to achieve same result.

Case 2: Determining Feature Status

For this scenario, let us imagine that our application has a number of features, the name of which are stored in an enum, but requires an API call to fetch a configuration object, to determine which of them are enabled, and which not. Now we want to turn the list of features into a dictionary, where key is feature name and value is its enabled or disabled state, represented by a boolean.

We will use the following hypothetical feature enumerations (both enum and as const for demo purposes):

enum  Features {
    SearchByName = 'searchByName',
    SearchBySurname = 'searchBySurname',
    SearchById = 'searchById',
}

const  FEATURES = {
    SearchByName: 'searchByName',
    SearchBySurname: 'searchBySurname',
    SearchById: 'searchById',
} as  const;
Enter fullscreen mode Exit fullscreen mode

With the following feature status interface and a config object:

interface  FeatureStatus {
    name: string;
    status: 'on' | 'off';
}

const  CONFIG: FeatureStatus[] = [
    {
        name: 'searchByName',
        status: 'on'
    },
    {
        name: 'searchBySurname',
        status: 'off'
    },
    {
        name: 'searchById',
        status: 'on'
    }
];
Enter fullscreen mode Exit fullscreen mode

Implementing feature checker is going to be easier here, since we do not have any extra arguments to pass, we are assuming the config object is already available inside the checker function:

const featureChecker: Converter<[], boolean> = (featureNames) => {
    const featuresStatus: Record<string, boolean> = {};
    const featuresSet = new  Map(CONFIG.map(f => ([f.name, f.status])));

    featureNames.forEach(name => {
        featuresStatus[name as  string] = featuresSet.get(name as  string) === 'on';
    });

    return featuresStatus;
}
Enter fullscreen mode Exit fullscreen mode

Thus checking feature availability is as easy as passing the list of features to be checked:

// for 'as const', resulting type 'Record<"searchByName" | "searchBySurname", boolean>'
const featuresAvailable = featureChecker([FEATURES.SearchBySurname, FEATURES.SearchByName]);
console.log(featuresAvailable);
// { "searchBySurname": false, "searchByName": true }

// for 'enum', resulting type 'Record<Features.SearchByName | Features.SearchBySurname, boolean>'
const featuresAvailable2 = featureChecker<Features.SearchBySurname | Features.SearchByName>([Features.SearchBySurname, Features.SearchByName]);
console.log(featuresAvailable2);
// { "searchBySurname": false, "searchByName": true }
Enter fullscreen mode Exit fullscreen mode

Once again, note how neater as const is.

That concludes it, how you learned something. I know I did :)

P.S. the code is available in the playground.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .