Search code examples
typescripttypescript-generics

Indexing type with generic keyof to type infer within an if-condition


I have a function which receives a key of a type to index an object or undefined to use for a class constructor.

class Instance {
  type: keyof OptionsMap
  constructor(type: keyof OptionsMap) {
    this.type = type
  }
}
class ClassA extends Instance {
  constructor(type: 'nothing') {
    super(type)
  }
}
class ClassB extends Instance {
  opts: Record<string, any>
  constructor(type: 'something', opts: Record<string, any>){
    super(type)
    this.opts = opts
  }
}
class ClassC extends Instance {
  opts: { name: string }
  constructor(type: 'thing', opts: { name: string }){
    super(type)
    this.opts = opts
  }
}
interface OptionsMap {
  nothing: undefined
  something: Record<string, any>
  thing: { name: string }
}

function foo<T extends keyof OptionsMap>(type: T, options: OptionsMap[T]): Instance {
  if (type === 'nothing') {
    return new ClassA(type)
  } else if (type === 'something') {
    return new ClassB(type, options)
  } else if (type === 'thing') {
    return new ClassC(type, options)
  } else {
    throw Error('Invalid type: ' + type)
  }
}

Playground

However, this throws:

Argument of type 'Record<string, any> | undefined' is not assignable to parameter of type 'Record<string, any>'.
  Type 'undefined' is not assignable to type 'Record<string, any>'

How can I make the options type-safe? I am using TypeScript 5.3.3


Solution

  • TypeScript currently (as of 5.3) can't really handle generics and control flow analysis at the same time. TypeScript can use control flow analysis to conclude that type === 'nothing' narrows the type of type, but it has no effect whatsoever on the type parameter T. So OptionsMap[T] is also unaffected, and you get errors.

    There is a longstanding open feature request at microsoft/TypeScript#33014 to allow control flow to affect generic types themselves. It even looks like this might become part of the language soon-ish, since it's mentioned in the TS5.5 roadmap (microsoft/TypeScript#57475). So maybe your code will just start working someday without change.

    Until and unless that happens, though, you'll need to work around it.


    So you will want to either refactor to use generics without control flow analysis, or control flow analysis without generics. Generic functions are mostly useful where the output type depends on the input type. But you've just got Instance as the return type. So let's try giving up on generics.

    If type and options were properties of a discriminated union type, then control flow analysis would kick in, so that checking the literal type of type would narrow the type of options.

    Now, as function parameters, they don't seem to be properties of a discriminated union type. But you could make your function take a rest parameter of a discriminated union tuple type, where the 0 index corresponds to type and the 1 index corresponds to options:

    type FooArgs = 
      [type: "nothing", options: undefined] | 
      [type: "something", options: Record<string, any>] | 
      [type: "thing", options: { name: string; }]
    
    function foo(...args: FooArgs): Instance {
        if (args[0] === 'nothing') {
            return new ClassA(args[0])
        } else if (args[0] === 'something') {
            return new ClassB(args[0], args[1])
        } else if (args[0] === 'thing') {
            return new ClassC(args[0], args[1])
        } else {
            throw Error('Invalid type: ' + args[0])
        }
    }
    

    (I used labeled tuple elements, but those are just for documentation; you still have to use 0 and 1 to index them, not type and options.) That code works, but it's a little annoying.

    The first annoyance is that we had to write out FooArgs manually, which is redundant. But we can make the compiler compute FooArgs from OptionsMap:

    type FooArgs = { [K in keyof OptionsMap]: 
      [type: K, options: OptionsMap[K]] 
    }[keyof OptionsMap]
    

    That's a discriminated object type as coined in microsoft/TypeScript#47109. It basically walks through each member of keyof OptionsMap and produces a two element tuple for each member, and then produces the union of those tuples.

    The second annoyance is all that args[0] and args[1] inside the function. Luckily, there is support for destructured discriminated unions, so you can copy the members of the rest parameter into type and options variables:

    function foo(...[type, options]: FooArgs): Instance {
        if (type === 'nothing') {
            return new ClassA(type)
        } else if (type === 'something') {
            return new ClassB(type, options)
        } else if (type === 'thing') {
            return new ClassC(type, options)
        } else {
            throw Error('Invalid type: ' + type)
        }
    }
    

    That's pretty close to being optional. It would be nice if you could just write type, options instead of ...[type, options], but you can't annotate pairs of parameters. If you're willing to refactor a function declaration to a const, though, then you can get there, by having the compiler infer the type of type and options contextually:

    const foo: (...args: FooArgs) => Instance = (type, options) => {
        if (type === 'nothing') {
            return new ClassA(type)
        } else if (type === 'something') {
            return new ClassB(type, options)
        } else if (type === 'thing') {
            return new ClassC(type, options)
        } else {
            throw Error('Invalid type: ' + type)
        }
    }
    

    Which looks good.


    So there you go. If you don't want to wait to see if generics and control flow analysis start working together, you can refactor away from generics to destructured discriminated unions and get the behavior you're looking for.

    Playground link to code