typescriptdiscriminated-union

Why does discriminated union work fine in one case, but doesn't work in another very similar one?


A.S.: The question is not about "Why is there an error?", but rather about "Why does the error go away if I barely change it?".

I have a relatively simple logic of creating parameters for a regex validator function that gathers the regex from either the combination of source and flags string inputs, or the regex input directly; it also takes a validation config in both cases. Some inputs are optional, some are required.

Here is how it looks like:

type Options = { foo: 'bar' }

type ArgsRegExp = [first: RegExp, second?: Options]
type ArgsString = [first: string, second?: string, third?: Options]
type Args = ArgsRegExp | ArgsString

interface Params {
  readonly pattern: RegExp
  readonly options?: Options
}

function getParams(args: ArgsRegExp): Params
function getParams(args: ArgsString): Params
function getParams([first, second, third]: Args): Params {
  if (first instanceof RegExp) {
    return {
      pattern: first,
      options: second,
//             ^^^^^^ Error!
    }
  }

  return {
    pattern: new RegExp( // weird new lines are explained in the playground 
      first,
      second
//    ^^^^^^ Error!
    ),
    options: third,
  }
}

Try it.

There is an error here: the second arg is always string | Options | undefined, and thus it is not assignable in neither cases (it must be Options | undefined in the first case and string | undefined in the second).

This error is expected, and (if I understand correctly) is related to https://github.com/microsoft/TypeScript/issues/30581

However, what is not expected is that a very similar piece of code (here's the diff view of them) works fine without producing any assignability issues, because it perfectly narrows down the items as you'd wish:

type Common = 'c'

type ArgsRegExp = [first: 'a0', second?: Common]
type ArgsString = [first: 'b0', second?: 'b1', third?: Common]
type Args = ArgsRegExp | ArgsString

interface Params {
  readonly custom: string
  readonly common?: Common
}

function getParams(args: ArgsRegExp): Params
function getParams(args: ArgsString): Params
function getParams([first, second, third]: Args): Params {
  if (first === 'a0') {
    return {
      custom: first,
      common: second,
//            ~~~~~~ No error?
    }
  }

  return {
    custom: // weird new lines are explained in the playground
      first +
      second,
//    ~~~~~~ No error?
    common: third,
  }
}

Try it.

What's up with that? Why does this work, but the original example doesn't? I get that the types are different, but I can't pinpoint exactly which difference contributes to the two examples behaving differently.


Solution

  • In

    type Options = { foo: 'bar' }
    type ArgsRegExp = [first: RegExp, second?: Options]
    type ArgsString = [first: string, second?: string, third?: Options]
    type Args = ArgsRegExp | ArgsString
    

    the type Args is not a discriminated union. Discriminated unions in TypeScript require their discriminant property to be or include literal types. You are switching on the first tuple element, which is either a RegExp or a string, neither of which are literal types. So that element is not seen as a discriminant, and therefore no narrowing is done when checking that element.

    On the other hand, in

    type Common = 'c'   
    type ArgsRegExp = [first: 'a0', second?: Common]
    type ArgsString = [first: 'b0', second?: 'b1', third?: Common]
    type Args = ArgsRegExp | ArgsString
    

    your first element is either of type "a0" or "b0", both of which are literal types. So Args here is a discriminated union, and you can check the first element and get the narrowing you want.