Home > OS >  Union type with incompatible signatures (2349) -- is this recursive type possible?
Union type with incompatible signatures (2349) -- is this recursive type possible?

Time:07-26

I want to build an API that looks like this:

export function GET({response}) {
   return response
     .match("text/html", "<p>Hello world</p>"),
     .match("text/plain", "Hello world"),
     .match("application/json", '{"message": "Hello world"}');

}

I have the start of a recursive function:

function makeMatch(list) {

  if(list.length === 0) {
    return "end"
  }

  return {
    match: item => makeMatch(list.filter(x => x !== item))
  }
}

Which is used like this:

const items = ["a", "b", "c"];

makeMatch(items).match("a").match("b").match("c"); // -> "end"

Here's what I'm trying to achieve with TypeScript:

makeMatch(items).match("a"); // ok
makeMatch(items).match("x"); // does not type check, "x" is not in ["a", "b", "c"]
makeMatch(items).match("a").match("b").match("a") // does not type check, you already took "a"

I've gotten this far:

// makeMatch(emptyArray) returns "end"
function makeMatch<T>(list: []): "end"

// makeMatch(notEmptyArray) recurses
function makeMatch<T>(list: T[]): T extends "never" ? "end" : { match: <Item extends T>(item: Item) => ReturnType<typeof makeMatch<Exclude<T, Item>>> } 
                                                
function makeMatch<T>(list: T[])
{
  if(list.length === 0) {
    return "end"
  }

  return {
    match: (item: T) => makeMatch(list.filter(x => x !== item))
  }
}

But when I try to use the function...

const x = makeMatch<"json" | "text" | "csv">(["json", "text", "csv"])

I get this error:

This expression is not callable.
  Each member of the union type `'(<Item extends "json">(item: Item) => Exclude<"json", Item> extends "never" ? "end" : { match: <Item extends Exclude<"json", Item>>(item: Item) => Exclude<Exclude<"json", Item>, Item> extends "never" ? "end" : { ...; }; }) | (<Item extends "text">(item: Item) => Exclude<...> extends "never" ? "end" : { ...; }) | (<I...'` has signatures, but none of those signatures are compatible with each other.(2349)

Is what I'm trying to accomplish possible with TypeScript? If so, how?

CodePudding user response:

TypeScript is often not able to understand complex function body logic. So when you return multiple different things like "end"

if (items.length === 0){
  return "end"
}

but also an object with a function match()

return {
  match: <I extends T>(item: I) => 
    makeMatch<Exclude<T, I>>(items.filter((x): x is Exclude<T, I> => x !== item))
}

TypeScript will not be able to follow this conditional logic and will just infer a union of both cases as the return type. So we will have to make our own conditional return type for the function which will be able to differentiate the result based on the input.

The generic type MakeMatchReturn covers both cases:

type MakeMatchReturn<T> = [T] extends [never]
  ? "end" 
  : {
      match: <I extends T>(item: I) => MakeMatchReturn<Exclude<T, I>>
  }

T is a union of the elements of the passed array. If there is no element left in the union, we can do the check if T extends never. It's wrapped in a tuple to avoid distribution of the union elements.

The resulting makeMatch function will be implemented like this:

function makeMatch<T extends string>(items: readonly T[]): MakeMatchReturn<T>  {
    if (items.length === 0){
        return "end" as unknown as MakeMatchReturn<T>
    }
    
    return {
        match: <I extends T>(item: I) => 
            makeMatch(items.filter((x): x is Exclude<T, I> => x !== item))
    } as MakeMatchReturn<T>
}

T is a union of the elements of the array passed to the function. There is also the generic type I which is the element passed to the match function. When we recursively call the makeMatch function, we have to use Exclude to remove the current element I from T.

Also note that we have to use as const here. Otherwise the tuple will be widened to be a string[] which does not hold any information about individual elements anymore.

const items = ["a", "b", "c"] as const;

const result = makeMatch(items).match("a").match("b").match("c")
//    ^? "end"

Playground

  • Related