'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