Search code examples
typescriptenumstypeguards

Type guard for allowed enum value with dynamic string?


I'm working with some data from a 3rd party that I want to convert into to a map by id, but only if the data is valid. I have an enum of allowed properties, but can't figure out how to check that the data is valid in a way the compiler will allow. I've attempted to check using an if statement with the in operator on the enum:

/** Allowed action values */
enum Actions {
    Update = 'Update',
    Delete = 'Delete'
}

/** Validated app events */
type AppEvent = {
    id: string;
    action: Actions;
}

/** 3rd party data I want to validate */
type RawEvent = {
    id: string;
    // ❗️I want to make sure this string is an allowed enum
    action: string
}

type AppEventsById = {
    [id: string]: AppEvent
}

const possiblyInvalidEvents: RawEvent[] = [
    {
        id: 'typescript',
        action: 'Update'
    },
    {
        id: 'uh oh',
        action: 'oops'
    }
]

const eventsById: AppEventsById = {}

possiblyInvalidEvents.forEach(event => {
    // ❓Here I'm attempting to check that 3rd party action field is one of the allowed enums
    if (event.action in Actions) {
        eventsById[event.id] = {
            id: event.id,
            // 💥Type 'string' is not assignable to type 'Actions'
            action: event.action
        }
    }
})
// => I want eventsById to include the { id: 'typescript' } object, but not { id: 'uh oh' }

The attempted assignment to action throws this error: Type 'string' is not assignable to type 'Actions'.


Solution

  • You want a user-defined type guard function to check if a string is a valid Actions member. This is a way to explicitly tell the compiler that some boolean-valued expression should be used to narrow the type of a value if it turns out to be true. The simplest refactoring of your code would be this:

    function isValidAction(str: string): str is Actions {
        return str in Actions;
    }
    
    possiblyInvalidEvents.forEach(event => {
        if (isValidAction(event.action)) {
            eventsById[event.id] = {
                id: event.id,
                action: event.action // no error anymore
            }
        }
    })
    

    This str in Actions check really relies on the fact that the enum's keys and values are identical, which might not always be true. I'd probably feel more comfortable checking the actual values of the enum and not the keys, which is a little more obnoxious to write out, but at least is less likely to suddenly break:

    function isValidAction(str: string): str is Actions {
        return (Object.keys(Actions) as Array<keyof typeof Actions>).
            some(k => Actions[k] === str);
    }
    

    But it's up to you. Okay, hope that helps; good luck!

    Playground link to code