Generic TypeScript Visitor and Mapper for String Enums and String Literal Union Types
NOTE: See DEPRECATION NOTICE!
- DEPRECATION NOTICE!
- What is it?
- Other TypeScript Enum Projects
- Quick Start
- Requirements
- General Usage and Terminology
- Handling Null/Undefined
- Handling Unexpected Values at Run Time
- Choosing to Not Handle Certain Values
- Visitor Method Return Values
- Being Explicit About Visitor/Mapper Result Type
- Visitor Method Parameters
- Sharing Visitor Methods Across Multiple Values
- Visiting/Mapping String Enums
- What's up with this chained
visitString().with()
syntax?
The functionality of ts-string-visitor
has been merged into ts-enum-util
(npm, github), and improved to support visiting/mapping numeric literal/enum values!
There will be no further development on ts-string-visitor
. Consider switching to ts-enum-util
,
unless you are stuck with TypeScript version prior to 2.9 (2.9 is minimum version supported by ts-enum-util
v4+ with Value Visitor/Mapper functionality). There is a migration guide link in the ts-enum-util
README to
help you with the transition.
ts-string-visitor
implements a visitor pattern for TypeScript string enum types, and TypeScript string literal union types. This helps you avoid bugs caused by forgetting to handle a possible value, and helps you quickly find code that needs to be updated after refactoring a string enum or string literal union type. If your code uses ts-string-visitor
and does not handle all possible values, the code will fail to compile. The compiler error messages will point you in the right direction to fix your code.
In addition to the very flexible visitor pattern, a much simpler "mapper" is also available for when you just need to directly map string literal or string enum values to some other value without any logic. All the same compiler checks apply to guarantee that you handle all possibilities.
Install via NPM:
npm i -s ts-string-visitor
Example of the generalized visitor.
import { visitString } from "ts-string-visitor";
type RGB = "r" | "g" | "b";
// Example function that uses visitString() to convert a RGB value
// to a display label
function getRgbLabel(rgb: RGB): string {
// Pass the value to visitString(), and provide a visitor
// implementation to visitString().with()
return visitString(rgb).with({
// The visitor must have a function property for every
// possible value of the string literal union type.
// TypeScript compilation will fail if you miss any values,
// or if you include extras that don't exist in the type.
r: () => {
return "Red";
},
g: () => {
// This function is called when "g" is passed in as the
// value for 'rgb'. The return value of this function is
// returned by visitString().with().
return "Green";
},
b: () => {
return "Blue";
}
});
}
const result = getRgbLabel("g"); // result === "Green"
Example of the simpler Mapper, for when you just need to map string literal or string enum values to some other value without any logic.
import { mapString } from "ts-string-visitor";
type RGB = "r" | "g" | "b";
// Example function that uses mapString() to convert a RGB value
// to a display label
function getRgbLabel(rgb: RGB): string {
// Pass the value to mapString(), and provide a mapper
// implementation to mapString().with()
return mapString(rgb).with({
// The mapper must have a property for every
// possible value of the string literal union type.
// TypeScript compilation will fail if you miss any values,
// or if you include extras that don't exist in the type.
r: "Red",
// This propery's value is looked up and returned when
// "g" is passed in as the value for 'rgb'.
g: "Green",
b: "Blue"
});
}
const result = getRgbLabel("g"); // result === "Green"
- TypeScript 2.7.1+: The entire purpose of
ts-string-visitor
is the compile-time checks. It won't do you any good in a JavaScript project. TypeScript 2.7.1 is the minimum supported version, due to a dependency onunique symbol
types. If you are stuck with an older version of TypeScript, then look at major version 2 ofts-enum-util
, which supports TypeScript versions 2.4.1+ - TypeScript's "strictNullChecks" option:
ts-string-visitor
helps ensure that you handlenull
andundefined
values where applicable. To support this, you must compile your project with "strictNullChecks" so that the compiler will treatnull
andundefined
as distinct types. This is not optional: code usingts-string-visitor
will fail to compile at all if "strictNullChecks" are not enabled. - ES6 Symbol: Be sure to include a
Symbol
polyfill if you plan to target environments/browsers that do not natively support it.
This section explains in general how to use ts-string-visitor
, and defines some terminology that is used throughout this README.
A visitor is used to execute a function based on the value of a string literal union or string enum type.
The visitString
method is used to "visit" a string literal or string enum value as follows:
[result] = visitString([value]).with([visitor])
Where:
[value]
is a value whose type is either a string literal union or a string enum.[visitor]
is an object whose property names match all possible values of[value]
's type, and the property values are functions that will be called when the corresponding property name value is passed tovisitString
.[result]
is the value returned by whichever visitor function is called. NOTE: Visitors are not required to return a value. You may choose to implement a visitor that only performs logic for each possible string literal/enum value.
Note: Every visitor method must have the same return type. You may want to consider Being Explicit About Visitor/Mapper Result Type.
See the Visitor usage example.
A mapper is used to simply convert the value of a string literal union or string enum type into some other value. This is less powerful than a visitor, but also simpler with less boilerplate code.
The mapString
method is used to "map" a string literal or string enum value as follows:
[result] = mapString([value]).with([mapper])
Where:
[value]
is a value whose type is either a string literal union or a string enum.[mapper]
is an object whose property names match all possible values of[value]
's type, and the property values are the mapped values that will be returned when the corresponding property name value is passed tomapString
.[result]
is the value of whichever[mapper]
property matched[value]
.
Note: Every property of your mapper must be of the same type. You may want to consider Being Explicit About Visitor/Mapper Result Type.
See the Mapper usage example.
The visitString
and mapString
methods are overloaded to handle every combination of its parameter being possibly null
and/or undefined
.
If (and only if) the parameter may be null
, then your visitor/mapper MUST include a property named handleNull
. The value of this property will be used to visit/map null
values.
If (and only if) the parameter may be undefined
, then your visitor/mapper MUST include a method named handleUndefined
. The value of this property will be used to visit/map undefined
values.
Example (Visitor):
type RGB = "r" | "g" | "b";
function getRgbLabel(rgb: RGB | null): string {
// The type of 'rgb' includes 'null', so the visitor must
// handle null
return visitString(rgb).with({
r: () => {
return "Red";
},
g: () => {
return "Green";
},
b: () => {
return "Blue";
},
handleNull: () => {
return "null";
}
});
}
const result = getRgbLabel(null); // result === "null"
Example (Mapper):
type RGB = "r" | "g" | "b";
function getRgbLabel(rgb: RGB | null): string {
// The type of 'rgb' includes 'null', so the mapper must
// handle null
return mapString(rgb).with({
r: "Red",
g: "Green",
b: "Blue",
handleNull: "null"
});
}
const result = getRgbLabel(null); // result === "null"
When processing data from an external source at run time (e.g., data from an API), there's no guarantee that it will be constrained to the expected types/values in your TypeScript code. Both visitString
and mapString
will detect unexpected values at run time. The default behavior is to throw an error when an unexpected value is encountered at run time. The encountered value is included in the error message for convenience.
If you would like to override the default behavior, then you may provide the optional handleUnexpected
property in your visitor or mapper implementation.
The parameter of the handleUnexpected
method in a visitor is of type string
, possibly unioned with type null
and/or undefined
, depending on whether null
/undefined
are unexpected values for the particular usage of visitString
.
See also: Visitor Method Parameters and Handling Null/Undefined.
Example (Visitor):
type RGB = "r" | "g" | "b";
function getRgbLabel(rgb: RGB): string {
return visitString(rgb).with({
r: () => {
return "Red";
},
g: () => {
return "Green";
},
b: () => {
return "Blue";
},
handleUnexpected: () => {
return "Unexpected!";
}
});
}
// Type casting to force an unexpected value at run time
const result = getRgbLabel(("blah" as any) as RGB); // result === "Unexpected"
Example (Mapper):
type RGB = "r" | "g" | "b";
function getRgbLabel(rgb: RGB): string {
return mapString(rgb).with({
r: "Red",
g: "Green",
b: "Blue",
handleUnexpected: "Unexpected!"
});
}
// Type casting to force an unexpected value at run time
const result = getRgbLabel(("blah" as any) as RGB); // result === "Unexpected!"
Sometimes you need to write code that is intentionally designed to only expect/handle a subset of possibilities, and you really just want to throw an error if one of the unsupported values is encountered. Simply provide visitString.unhandled
or mapString.unhandled
as the entry for an unhandled value in a visitor/mapper implementation, and an error will be thrown if that value is encountered at runtime.
Example (Visitor):
type RGB = "r" | "g" | "b";
function getRgbLabel(rgb: RGB): string {
return visitString(rgb).with({
r: () => {
return "Red";
},
g: visitString.unhandled,
b: () => {
return "Blue";
}
});
}
// Throws error: "Unhandled value: g"
const result = getRgbLabel("g");
Example (Mapper):
type RGB = "r" | "g" | "b";
function getRgbLabel(rgb: RGB): string {
return mapString(rgb).with({
r: "Red",
g: mapString.unhandled,
b: "Blue"
});
}
// Throws error: "Unhandled value: g"
const result = getRgbLabel("g");
Your visitor methods can return a value, which will be returned by the call to visitString().with()
.
BEWARE: All visitor methods within a given visitor MUST have the same return type. If you have a mixture of return types, then the compiler will decide that one of them is correct, and the others are wrong. The resulting compiler error may be confusing if you and the compiler do not agree on what the correct return type should have been.
Keep reading to learn how to avoid this confusion...
When designing a visitor/mapper to return a value, it is often helpful to explicitly provide the desired return type as a template parameter to the with()
function.
Example (Visitor):
type RGB = "r" | "g" | "b";
function getRgbLabel(rgb: RGB): string {
// Tell the compiler that you intend to return a string from the
// visitor
return visitString(rgb).with<string>({
// Compiler error for this property
r: () => {
return 10;
},
g: () => {
return "Green";
},
b: () => {
return "Blue";
}
});
}
Example (Mapper):
type RGB = "r" | "g" | "b";
function getRgbLabel(rgb: RGB): string {
// Tell the compiler that you intend to return a string from the
// mapper
return mapString(rgb).with<string>({
// Compiler error for this property
r: 10,
g: "Green",
b: "Blue"
});
}
In the above examples, it is guaranteed that the compiler will complain about the "r"
property being/returning type number
instead of string
. If you did not provide the hint then the compiler may infer the return type of the visitor/mapper to be number
and confusingly complain that the OTHER properties are wrong!
The methods of the visitor implementation receive a single parameter: the value being visited. The type of the parameter for each method is EXACTLY the type of the value handled by that method. Here's a simple (albeit pointless) identity visitor implementation to demonstrate:
type RGB = "r" | "g" | "b";
function rgbIdentity(rgb: RGB | null | undefined): RGB | null | undefined {
return visitString(rgb).with({
r: (value) => {
// type of 'value' is exactly "r"
// (not RGB | null | undefined)
return value;
},
g: (value) => {
// type of 'value' is exactly "g"
return value;
},
b: (value) => {
// type of 'value' is exactly "b"
return value;
},
handleNull: (value) => {
// type of 'value' is exactly null
return value;
},
handleUndefined: (value) => {
// type of 'value' is exactly undefined
return value;
}
});
}
const result = rgbIdentity("g"); // result === "g"
Sometimes you want to handle multiple values in the same way, but duplicating code is bad. Here's some examples of how you can share code across multiple values in a visitor.
If your shared code does not need to reference the value being visited, then it is very simple to share visitor methods across multiple values:
type RGB = "r" | "g" | "b";
// test if a color value is "supported"
function isSupportedColor(rgb: RGB | null | undefined): boolean {
// pre-define a handler for all "supported" values
const handleSupported = (): boolean => {
return true;
};
// pre-defined a handler for all "unsupported" values
const handleUnsupported = (): boolean => {
return false;
};
return visitString(rgb).with<boolean>({
r: handleSupported,
// Green is ugly - UNSUPPORTED!
g: handleUnsupported,
b: handleSupported,
handleNull: handleUnsupported,
handleUndefined: handleUnsupported
});
}
If your shared code needs to reference the value being visited, then you have to be conscious of the parameter types involved. The type of the parameter of the shared method must include the types of all values it will handle. Let's enhance the previous example to log every value that is visited.
type RGB = "r" | "g" | "b";
// test if a color value is "supported"
function isSupportedColor(rgb: RGB | null | undefined): boolean {
// Since this handler is not used for null/undefined, there's no
// need to include those types for the param.
// The type technically only needs to be ("r" | "b"), but type
// RGB is more convenient and there's no harm in being overly
// permissive in this case.
const handleSupported = (value: RGB): boolean => {
// Since the type 'value' does not include null/undefined, we
// can safely call value.toupperCase() without performing a
// null check first.
// This is an example of why being restrictive with the type
// of shared handler can be beneficial.
console.log(`handling supported value: ${value.toUpperCase()}`);
return true;
};
// This handler is used to handle null/undefined, so it MUST
// include those types for the param.
// Again, the type only technically needs to be
// ("g" | null | undefined), but being more permissive can be
// more convenient when it's not harmful.
const handleUnsupported = (value: RGB | null | undefined): boolean => {
console.warn(`unsupported color encountered: ${value}`);
return false;
};
return visitString(rgb).with<boolean>({
r: handleSupported,
// Green is ugly - UNSUPPORTED!
g: handleUnsupported,
b: handleSupported,
handleNull: handleUnsupported,
handleUndefined: handleUnsupported
});
}
TypeScript string enums can also be visited/mapped with ts-string-visitor
. The important detail to understand is that the values (not the identifiers/names) of the enums are used as the visitor/mapper property names.
enum RGB {
// "R" is the name of the identifier.
// "r" is the value.
R = "r",
G = "g",
B = "b"
}
function getRgbLabel(rgb: RGB): string {
return visitString(rgb).with<string>({
// This works (my preferred style)
[RGB.R]: () => {
return "Red";
},
// This also works
g: () => {
return "Green";
},
// This does NOT work!
B: () => {
return "Blue";
}
});
}
NOTE: My preferred style of using the enum value as a computed property name ([RGB.R]
) has limited support in some versions of TypeScript. See Known Issues.
Be aware that the type of a string enum value is a more specific type than a string literal type. For maximum compile-time type checking benefit, you should treat enums as enum types whenever possible, rather than string literals:
- Compare against members of the enum, rather than string literals.
- Use the enum type for variables, params, return types, etc., rather than type string.
ts-string-visitor
can only work on string enums that qualify as "union enums":
- All members are of type string (no number values!).
- All members have literal (non-calculated) values.
Read more about "Union enums and enum member types" here: Enums - TypeScript
You might wonder why I didn't implement ts-string-visitor
as a single overloaded visitString
method that accepts both the value AND the visitor. The chained approach I settled on was necessary to:
- Ensure that the type of visitor (whether it needs to handle null and/or undefined) is driven by whether the visited value may possibly be null/undefined. This is necessary to provide relevant compiler error messages when something isn't right with your code.
- Allow the return type to be explicitly provided, while allowing the compiler to infer the type of the visited value.
Read more details about other approaches I tried and their flaws in this github issue comment.