How can you distinguish between multiple template literal string types? If I define
type UnionTypeExample = `${string}_id` | `${string}_username`
with
x: UnionTypeExample
how can I check if x
is an id or a username? The temptation is to use some combination of includes()
and "string"
, but this does not work with type guarding.
Right now I get errors like
Argument of type '`https://${string}` | `${string}_id`' is not assignable to parameter of type '`https://${string}`'.
Type '`${string}_id`' is not assignable to type '`https://${string}`'.(2345)
Code example is below, but this typescript playground example shows the error more clearly.
type Url = `https://${string}`;
type ID = `${string}_id`;
type AttributeType = number | Date | Url | ID;
type AttributeLabelProps = {
attribute: number | Date | Url;
}
const handleNumber = (val: Number) => console.log(`${val}: is a number!`)
const handleDate = (val: Date) => console.log(`${val}: is a date!`)
const handleUrl = (val: Url) => console.log(`${val}: is a url!`)
const handleID = (val: ID) => console.log(`${val}: is an id!`)
function AttributeLabel(attribute: AttributeType) {
if (typeof attribute === "number") {
handleNumber(attribute)
}
else if (attribute instanceof Date) {
handleDate(attribute)
}
// What is the correct runtime check to narrow this type from Url | ID to just Url?
else if (typeof attribute === "string" && attribute.startsWith("https://")) {
handleUrl(attribute) // error!
// ~~~~~~~~~
// Argument of type '`https://${string}` | `${string}_id`' is
// not assignable to parameter of type '`https://${string}`'.
}
else {
handleID(attribute)
// ~~~~~~~~~
// Argument of type '`https://${string}` | `${string}_id`' is
// not assignable to parameter of type '`${string}_id`'.
}
};
//This is what I would expect the output to be:
console.log(AttributeLabel(1)) // 1 is a number!
console.log(AttributeLabel(new Date())) // [TODAY] is a date!
console.log(AttributeLabel("https://google.com")) // https://google.com is a url!
console.log(AttributeLabel("dog_id")) // dog_id is an id!
TypeScript doesn't interpret calling the startsWith()
string method as a way of narrowing it to an appropriate pattern template literal type. The call signature for startsWith()
in the TypeScript library just returns boolean
, not a type predicate. This call signature predates the existence of template literal types in TypeScript, and the subsequent suggestion in microsoft/TypeScript#46958 to change it to a type guard was declined. So it's not part of the language, and you'll need to work around it.
If you want to see startsWith()
behave like a type guard in your code base, you can do so by merging in an appropriate call signature:
interface String {
startsWith<T extends string>(t: T): this is `${T}${string}`;
}
And then your code will just work without needing refactoring:
function AttributeLabelPlain(attribute: AttributeType) {
if (typeof attribute === "number") { handleNumber(attribute) }
else if (attribute instanceof Date) { handleDate(attribute) }
else if (typeof attribute === "string" && attribute.startsWith("https://")) {
handleUrl(attribute) // okay
}
else { handleID(attribute) } // okay
};
Of course you might not want to do this. The suggestion to do it globally was declined because it makes the behavior of startsWith()
more complicated for the compiler, and because it can lead to performance problems if the string literal types being considered are unions.
Another approach, which is more self-contained, is to refactor your check into its own custom type guard function, so that only the call to that function acts as a type guard instead of every invocation of startsWith()
. Like this:
function isUrl(x: any): x is Url {
return typeof x === "string" && x.startsWith("https://");
}
function AttributeLabel(attribute: AttributeType) {
if (typeof attribute === "number") { handleNumber(attribute) }
else if (attribute instanceof Date) { handleDate(attribute) }
else if (isUrl(attribute)) { handleUrl(attribute) } // okay
else { handleID(attribute) } // okay
};
I'd probably do the latter, although you'll need to be careful that the compiler just believes that your isUrl()
is implemented correctly. If you ever change the definition of Url
then you'll need to remember to change the implementation accordingly; no error message will be issued.