TypeScript Replace switch-case logic with Record object

eye-catchJavaScript/TypeScript
Sponsored links

Switch-case is one of the basics for conditional statements and it is supported by many programming languages. It is useful but sometimes, a function has a long switch-case clause that might not be readable. If there are many casekeywords to select a proper statement, a linter complains that the function is too complex. Adding a comment line to ignore the rule is the easiest solution… I don’t like this suppression.

This post offers you a different solution.

Sponsored links

Detect coding error by using never type in switch-case

You have ever likely written like the following switch-case.

function getStatusCode(value: string) {
    switch (value) {
        case "running": return 1;
        case "stop": return 2;
        case "aborted": return 3;
        case "finished": return 4;
        default: throw new Error(`Unexpected value [${value}]`);
    }
}

This simple switch case might be written as an example in a language tutorial. If we already know the possible values, we would replace the string with an enum or enum-like object.

const Status = {
    Running: "running",
    Stop: "stop",
    Aborted: "aborted",
    Finished: "finished",
} as const;
// type StatusType = "running" | "stop" | "aborted" | "finished"
type StatusType = typeof Status[keyof typeof Status];

function getStatusCode2(value: string) {
    switch (value) {
        case Status.Running: return 1;
        case Status.Stop: return 2;
        case Status.Aborted: return 3;
        case Status.Finished: return 4;
        default: throw new Error(`Unexpected value [${value}]`);
    }
}

If the argument data type is Statusdefined above, we can write in the following way.

function selectBySwitch(value: StatusType) {
    switch (value) {
        case Status.Running: return 1;
        case Status.Stop: return 2;
        case Status.Aborted: return 3;
        case Status.Finished: return 4;
        default:
            const check: never = value;
            throw new Error("Add the new value");
    }
}

With this code, the compiler tells us a coding error if we add a new value into a Statusobject but don’t add an additional code to the switch-case because the missed value is assigned to never type variable. Without using never type, we don’t know the error until the program reaches here at runtime. You might want to check this post as well.

This switch-case looks readable but a linter complains if it’s big. Are there any other solutions?

Sponsored links

Compiler does not show an error for duplicated key in Map object

Firstly, let’s consider using Map.

const StatusMap = new Map<StatusType, number>([
    [Status.Running, 1],
    [Status.Stop, 2],
    [Status.Aborted, 3],
    [Status.Finished, 4],
    [Status.Finished, 5],
]);

const StatusMap2 = new Map<StatusType, number>([
    ["running", 1],
    ["stop", 2],
    ["aborted", 3],
    ["finished", 4],
    ["finished", 5],
]);

These objects have the same key in it but the compiler doesn’t show an error. If it doesn’t show an error, it can’t be used as a replacement of switch-case. If we use Map, the code will look like this as a replacement of the switch-case.

// If the value is StatusType
console.log(StatusMap.get(value));

// If the value is unknown string
function selectByStatusMap(value: string) {
    const result = StatusMap.get(value as StatusType);
    if (result === undefined) {
        throw new Error(`Unexpected value [${value}]`);
    }
    return result;
}

But, as I said above, we can’t catch the coding error at compile time. Don’t apply this way.

Map like object cannot recognize the missing key

Secondly, let’s use Map like object.

const StatusMapObj = {
    [Status.Running]: 1,
    [Status.Stop]: 2,
    [Status.Aborted]: 3,
    [Status.Finished]: 4,
    [Status.Finished]: 5,
} as const;

This is just an object. It looks like a Map object and we can use this object in a similar way to Map but it provides neither has nor get functions. The compiler doesn’t show an error as well as the previous one as of version 4.5.5. The bug report is in official GitHub issue page.

But we can define it in a similar way. If we don’t use a bracket for a key, the compiler shows the error.

const StatusMapObj2 = {
    running: 1,
    stop: 2,
    aborted: 3,
    finished: 4,
    // An object literal cannot have multiple properties with the same name in strict mode.ts(1117)
    // Duplicate identifier 'finished'.ts(2300)
    // finished: 5,
} as const;

It seems to be good at first glance… What if one of possible values is missing? We expect that the compiler shows an error if one of possible keys is missing. However, it doesn’t show an error because it doesn’t know which key can be specified for the keys.

If we use Map like object, the code will look like this as a replacement of the switch-case.

// If the value is StatusType
console.log(StatusMapObj[value]);

// If the value is unknown string
function selectByStatusMapObj(value: string) {
    const result = StatusMapObj[value as StatusType];
    if (result === undefined) {
        throw new Error(`Unexpected value [${value}]`);
    }
    return result;
}

Don’t apply this technique either because an error can happen at runtime.

Using Record type for a replacement of switch-case

Lastly, Record type object! It is likewise Map like object but possible keys are added. Therefore, the compiler can tell us the missing key and unexpected key.

const StatusMapWithString: Record<StatusType, number> = {
    running: 1,
    stop: 2,
    aborted: 3,
    finished: 4,
    // An object literal cannot have multiple properties with the same name in strict mode.ts(1117)
    // Duplicate identifier 'finished'
    // finished: 5,
} as const;

If one of the possible keys is omitted from the object, the following error appears. This is what we want.

// Property 'stop' is missing in type '{ readonly running: 1; readonly aborted: 3; readonly finished: 4; }'
// but required in type 'Record<StatusType, number>'
const StatusMapWithString: Record<StatusType, number> = {
    running: 1,
    // stop: 2,
    aborted: 3,
    finished: 4,
} as const;

The logic to get the corresponding value is the same as other examples shown above.

// If the value is StatusType
console.log(StatusMapRecord[value]);

// If the value is unknown string
function selectByStatusMapRecord(value: string) {
    const result = StatusMapRecord[value as StatusType];
    if (result === undefined) {
        throw new Error(`Unexpected value [${value}]`);
    }
    return result;
}

Conclusion

Let’s use Record type object instead of a long switch-case for simple value mapping.

By using Record type object, the following original function

function getStatusCode(value: string) {
    switch (value) {
        case "running": return 1;
        case "stop": return 2;
        case "aborted": return 3;
        case "finished": return 4;
        default: throw new Error(`Unexpected value [${value}]`);
    }
}

will be as follows.

function selectByStatusMapRecord(value: string) {
    const result = StatusMapRecord[value as StatusType];
    if (result === undefined) {
        throw new Error(`Unexpected value [${value}]`);
    }
    return result;
}

function getStatusCode(value: string) {
   return selectByStatusMapRecord(value);
}

The more possible values the switch-case has, the longer the clause will be. It leads to a linter error due to the complexity but this technique keeps the complexity even if we have hundreds of possible values.

Check this post as well if you are interested in the performance.

Comments

Copied title and URL