Search code examples
typescriptobjecttype-inferencetypeof

Enforce a type only on defined members of an object, without widening the 'keysof typeof [object]' to string[]?


So I've created a type:

type Status = {
    name: string,
    description: string,
    statusLightColor: string
}

And I've got an object whose members I want to be of type Status

const ListOfStatuses = {
    not_started: {name: "Not Started", description: "The work has not started.", statusLightColor: "Red"},
    in_progress: {name: "In Progress", description: "The task is currently in progress and will be complete soon.", statusLightColor: "Yellow"},
    is_finished: {name: "Is Finished", description: "The work is finished.", statusLightColor: "Green"},
}

And finally I've got another type which I want to limit to the keys of ListOfStatuses. So I do this:

type StatusID = keyof typeof ListOfStatuses

And magically, it totally works because typescript is creating an inference type whose indexes are limited to the keys of ListOfStatuses, and my IDE shows type StatusID = "not_started" | "in_progress" | "is_finished"

But here's the problem

In order for this to work, I've had to intentionally not define the type of ListOfStatuses so TypeScript can infer it. But now, if I wanted to add another Status to ListOfStatuses and I wasn't careful, TS would not enforce the type Status upon members of ListOfStatuses.

If I define the type like so: const ListOfStatuses : { [x: string]: Status } = {...}, type is enforced but now StatusID breaks and widens as: type StatusID = string | number.

How can I enforce the type Status upon members of ListOfStatuses without widening the type of its keys?

Why I want to do this:

The purpose of the type StatusID is to create a lightweight reference to a Status object which holds more detailed information, since there will only ever be (in this case) 3 valid Statuses, and there will be many objects which need a defined status, which will be sent back and forth over the network.

Following best practices to reduce repetition, I wanted to create a solution that allowed me or a future developer to only edit a single object within the codebase to add or remove Statuses.

I thought about using an interface for ListOfStatuses but I'm not sure how to readily access its values in other parts of the codebase. Additionally (I'm not sure if this is relevant), I'd like to avoid using a function call to fetch a Status object because I'm not sure if it will play well with Angular's change detection when used with binding.

This is a solution that I've come up with, but it's rather inelegant because you still have to edit two different things, although at least it throws a compile error if the ListOfStatuses and StatusID are mismatched.

type StatusID = 'not_started' | 'in_progress' | 'is_finished';
const ListOfStatuses: { [key in StatusID]: Status } = {...}

Obviously it's not that much of a hassle to update both in this example, but it could get annoying in a list of many more objects, and I'd like to streamline the process of modifying details of the codebase for myself and other devs in the future.


Solution

  • First off, I would recommend using [key: string]: Status since if you add/remove a status later on, it could break unrelated parts which depended on hard coded properties rather than iterating over members.

    Although there is a solution to this problem using temporary variables, though this could be hidden with an inline function.

    type Status = {
        name: string,
        description: string,
        statusLightColor: string
    }
    
    const temp = {
      not_started: {
        // name: "Not Started",
        description: "The work has not started.",
        statusLightColor: "Red"
      },
      in_progress: { name: "In Progress", description: "The task is currently in progress and will be complete soon.", statusLightColor: "Yellow" },
      is_finished: { name: "Is Finished", description: "The work is finished.", statusLightColor: "Green" },
    };
    
    const ListOfStatuses: {
      [key in keyof typeof temp]: Status
    } = temp; // This now throws an error that TS cannot convert from typeof temp to Status.