String Literal Type Guard in TypeScript
String literal types are a lightweight alternative to string enums. With the introduction of the const assertions in TypeScript 3.4, even type guards can be implemented in a DRY manner.
Imagine the following type:
export interface FilterValue {
key: FilterKey;
value: string;
}
Where FilterKey
is a string literal type:
export type FilterKey = 'name' | 'surname';
Keeping these in mind, let's implement a function that converts an object with key/value pairs (e.g. from a parsed query string) to a FilterValue
array (skipping any unsupported keys):
export const FILTER_KEYS: FilterKey[] = ['name', 'surname'];
export function parseFilter(queryParams: {
[key: string]: string;
}): FilterValue[] {
const filter: FilterValue[] = [];
for (const key in queryParams) {
if (FILTER_KEYS.includes(key as FilterKey)) {
filter.push({ key: key as FilterKey, value: queryParams[key] });
}
}
return filter;
}
You can notice two issues with this code:
- The list of allowed literal values is repeated in the
FILTER_KEYS
array. - Type safety is circumvented by explicitly casting the
key
variable toFilterKey
when instantiating aFilterValue
:
filter.push({ key: key as FilterKey, value: queryParams[key] });
The latter issue can be fixed with a type guard:
export function isFilterKey(key: string): key is FilterKey {
return FILTER_KEYS.includes(key as FilterKey);
}
export function parseFilter(queryParams: {
[key: string]: string;
}): FilterValue[] {
const filter: FilterValue[] = [];
for (const key in queryParams) {
if (isFilterKey(key)) {
filter.push({ key, value: queryParams[key] });
}
}
return filter;
}
Now, typecasting isn't needed in the parseFilter
function anymore. The compiler trusts the isFilter
type guard that the key
value is of FilterKey
type. As long as it's implemented correctly, an invalid value can't be put into a FilterValue
by incorrectly applying an explicit cast.
To avoid repeating the string literals in the FILTER_KEYS
array, const assertion can be used:
export const FILTER_KEYS = ['name', 'surname'] as const;
export type FilterKey = typeof FILTER_KEYS[number];
The types FILTER_KEYS
and FilterKeys
remained identical to before. They are just defined differently.
Here's the full final code:
export const FILTER_KEYS = ['name', 'surname'] as const;
export type FilterKey = typeof FILTER_KEYS[number];
export interface FilterValue {
key: FilterKey;
value: string;
}
export function isFilterKey(key: string): key is FilterKey {
return FILTER_KEYS.includes(key as FilterKey);
}
export function parseFilter(queryParams: {
[key: string]: string;
}): FilterValue[] {
const filter: FilterValue[] = [];
for (const key in queryParams) {
if (isFilterKey(key)) {
filter.push({ key, value: queryParams[key] });
}
}
return filter;
}
You can check the code at each step as separate commits in the corresponding repository on GitHub.
With TypeScript features such as const assertions and type guards, string literal types can be enhanced to provide even stronger type safety.