TylerR909
TylerR909

Reputation: 118

Function return type based on optional params

I'm trying to access an array of data stored in React Context using a custom hook.

type Data = { id: string };

const useMyData = (id?: string) => {
  const data: Data[] = useContext(MyDataProvider);
  if (id) return data.find(item => item.id === id); // type: Data
  return data; // type: Data[]
}

What I've Tried

I've tried function overloading, but I get This overload signature is not compatible with its implementation signature.

function useMyData(): Data[]
function useMyData(id?: string): Data { /* ... */ }

I've tried Generics to no avail: Type 'Data' is not assignable to type 'T extends undefined ? Data[] : Data

const useMyData = <T extends string | undefined>(
  id?: T,
): T extends undefined ? Data[] : Data => { /* ... */ };

I've tried Typing the hook, but I get every combination of "Is not assignable to" error you could imagine. Data | Data[] is not assignable to Data[], unknown is not assignable to Data, etc.

type TUseMyData = (() => Data[]) | ((id: string) => Data);

const useMyData: TUseMyData = (id?: string) => { /* ... */ };

Upvotes: 0

Views: 158

Answers (1)

jcalz
jcalz

Reputation: 328262

I'll use this implementation to start with:

const theData: Data[] = [{ id: "a" }, { id: "b" }, { id: "c" }];
const useMyDataUnion = (id?: string) => {
    if (typeof id === "string")
        return theData.find(item => item.id === id); // type: Data | undefined
    return theData; // type: Data[]
}

Note that the return type is either Data[] or Data | undefined, since Array.prototype.find() is not guaranteed to find anything. That function has the type signature

// const useMyDataUnion: (id?: string | undefined) => Data | Data[] | undefined

which is true but not specific enough for your needs.


First we'll do it with overloads:

function useMyDataOverload(): Data[]; // call signature 1
function useMyDataOverload(id: string): Data | undefined; // call signature 2
// implementation:
function useMyDataOverload(id?: string) {
    return useMyDataUnion(id);
}

Note that overloads separate the call signatures from the implementation of a function, and the implementation of a function is not added to the list of call signatures. In your example you were treating the implementation as if it were another call signature, and the compiler was unhappy.

For useMyDataOverload(), there are two ways to call it. You can either call it with no parameters and get a Data[] out, or call it with a string parameter and get a Data | undefined out. The implementation has to accept both possible ways of calling it... a single optional string parameter meets that criterion. It also has to return a type that is compatible with the union of return types of the call signatures. It also meets that criterion. Note that the return type rule is necessary but not sufficient for type safety. You could just as easily change the implementation to something like:

function useMyDataBadOverload(): Data[];
function useMyDataBadOverload(id: string): Data | undefined;
function useMyDataBadOverload(id?: string) {
    return Math.random() < 0.5 ? theData.find(i => Math.random() < 0.5) : theData;
}

which certainly returns a Data[] | Data | undefined but is not guaranteed to return the right type. So be careful with implementing an overloaded function.

Let's see if it works:

console.log(useMyDataOverload().map(x => x.id)); // ["a", "b", "c"] 
console.log(useMyDataOverload("a")?.id); // "a"
console.log(useMyDataOverload("z")?.id); // undefined

That all looks right, and the compiler accepts it. I think overloads are probably the right solution here. One drawback is that you can only call one call signature at a time; if the compiler can't tell which one you're calling, there will be an error:

const params = Math.random() < 0.5 ? [] as const : ["a"] as const;
useMyDataOverload(...params); // error, no acceptable overload

You could also try to use a generic function with conditional types. My implementation would look like this:

const useMyDataConditional = <T extends [id: string] | []>(
    ...params: T
) => useMyDataUnion(params[0]) as T extends [] ? Data[] : (Data | undefined);
// need type assertion

This is using T as the type of the parameter list, and is either a single string parameter or an empty parameter list. The return type is conditional similar to what you were doing.

But as you noticed, the compiler is unable to verify that the return value is assignable to the generic conditional return type. This is true of overloads, also, but with overloads the compiler is intentionally "lax" about type checking and lets things through it can't be sure about. Here, it is "strict" and lets just about nothing through. If I just annotate the function, I'll get an error:

const useMyDataBadConditional = <T extends [id: string] | []>(
    ...params: T): T extends [] ? Data[] : (Data | undefined) =>
    useMyDataUnion(params[0]); // error

There is an open issue, microsoft/TypeScript#33912, asking for some way the compiler can inspect a function implementation to verify that it is assignable to a generic conditional type. For now though, the solution I'd recommend is a type assertion. Don't ask the compiler if the return type is T extends [] ? Data[] : (Data | undefined); instead, you tell it so with as.

Let's make sure it works:

console.log(useMyDataConditional().map(x => x.id)); // ["a", "b", "c"] 
console.log(useMyDataConditional("a")?.id); // "a"
console.log(useMyDataConditional("z")?.id); // undefined
useMyDataConditional(...params); // Data | Data[] | undefined

All of that works as expected, and even the ...params version is supported. Personally I'd lean toward overloads here, as the conditional type is more complex and doesn't give a huge benefit. But either way works.


Playground link to code

Upvotes: 1

Related Questions