'Nominal Type for String Literals
I like to use string literals as discriminated unions for many reasons, including to use the never type to check exhaustiveness in control flow. For example:
type PossibleStrings = "foo" | "moo" | "goo"
const stringHandler = (str:PossibleStrings) => {
switch(str) {
case "foo":
return
case "moo":
return
case "goo":
return
default:
const x:never = str
}
}
const badStringHandler = (str:PossibleStrings) => {
switch(str) {
case "foo":
return
case "moo":
return
default:
const x:never = str // Error because a case is missing. Yay.
}
}
I also like to use nominal types for situations where I want structurally identical types to be incompatible. I typically use the "branded interface" method (i.e., the third method here). For example:
interface Nominal extends String {
_brand: string
}
const toNominal = (str:string)=>str as unknown as Nominal
const x:Nominal = "foo" // Error as expected
const y:Nominal = toNominal("foo")
I've been trying to find some way to combine these two things, i.e., to have nominally typed strings that behave like string literals for purposes of exhaustiveness checking.
So, I created an extension of nominal type that uses the the string literal itself as an additional brand. That way these "nominal literal" types will still be type compatible with the "wider" nominals. But they can also be used like literals in the sense that they can be used as a narrow type that is incompatible but anything other than another nominal constructed with the same literal. An example should make that clearer:
interface NominalLiteral<T extends string> extends Nominal {
_literalBrand: T
}
const toNominalLiteral = <T extends string>(str:T)=>str as unknown as NominalLiteral<T>
type PossibleNominals = NominalLiteral<"foo"> | NominalLiteral<"goo"> | NominalLiteral<"moo">
let a:PossibleNominals = toNominal("foo") //Error as expected
let b:PossibleNominals = toNominalLiteral("foo")
let c:PossibleNominals = toNominalLiteral("gar") //Error as expected
let d: Nominal = toNominalLiteral("foo") // Still type compatible with the wider nominal type
All well and good. The problem is--Typescript doesn't seem to be able to detect when a set of these NominalLiteral types has been exhausted. So this doesn't work:
type PossibleNominals = NominalLiteral<"foo"> | NominalLiteral<"goo"> | NominalLiteral<"moo">
const nominalHandler = (nom:PossibleNominals) => {
switch(nom) {
case toNominalLiteral("foo"):
return
case toNominalLiteral("moo"):
return
case toNominalLiteral("goo"):
return
default:
const x:never = nom //Error here. TS doesn't realize that we've exhausted the PossibleNominals
}
}
Any ideas on why this doesn't work? Am I missing something easy or is this a TS limitation? Is there any other way to make this work?
EDIT: Forgot the playground link.
Solution 1:[1]
This would work, although a bit ugly:
interface NominalLiteral<T extends string> extends Nominal {
_literalBrand: T
}
const toNominalLiteral = <T extends string>(str:T)=> {
const r = `${str}` as unknown as NominalLiteral<T>;
r._literalBrand = str;
return r;
}
type PossibleNominals = NominalLiteral<"foo"> | NominalLiteral<"goo"> | NominalLiteral<"moo">
const nominalHandler = (nom: PossibleNominals) => {
switch(nom._literalBrand) {
case "foo":
return
case "moo":
return
case "goo":
return
default:
const x: never = nom
}
}
It makes ts handle it as algebraic data types.
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
| Solution | Source |
|---|---|
| Solution 1 | Igor Loskutov |
