Search code examples
typescriptcomposition

How to create generic composing functions for discriminated unions


I am probably experimenting with the limits of TypeScript's system here but is it at all possible to properly define makeIsBox to create isBoxA by composing over isA?

type A = 'A'
type B = 'B'
type Letter = A | B

const makeIsLetter = <L extends Letter>(checkLetter: L) => (letter: Letter): letter is L => letter === checkLetter

const isA = makeIsLetter('A')
const isB = makeIsLetter('B')

// Test
const takeA = (a: A) => void 0
const takeB = (b: B) => void 0

declare const a: Letter
declare const b: Letter

takeA(a) // should fail

if(isA(a)) {
  takeA(a) // should not fail
} else {
  takeA(a) // should fail
  takeB(a) // should not fail>
}

// So far so good.


type BoxA = { type: A, value: string }
type BoxB = { type: B, status: number }
type Box = BoxA | BoxB

const makeIsBox = <
  L extends Letter,
  Fn extends (letter: Letter) => letter is L
>( 
  fn: Fn
) => <B extends Box>(box: Box) => fn(box.type)

const isBoxA = makeIsBox(isA)

declare const box: Box

if (isBoxA(box)) {
  const value = box.value
}

TypeScript Playground


Solution

  • TypeScript won't infer a user-defined type guard for you. You have to annotate it yourself, like this:

    const makeIsBox = <L extends Letter>(fn: (letter: Letter) => letter is L) => (
      box: Box
    ): box is Extract<Box, { type: L }> => fn(box.type);
    

    This still only needs to be generic in L. It takes a function of type (l: Letter)=>l is L and returns a function of type (b: Box)=>b is Extract<Box, {type: L}>. That's using the Extract utility type to select just the member of the Box union which is assignable to {type: L}.

    It should work the way you want:

    const isBoxA = makeIsBox(isA);
    
    declare const box: Box;
    
    if (isBoxA(box)) {
      const value = box.value;
    } else {
      const status = box.status;
    }
    

    Hope that helps; good luck!

    Link to code