Reputation: 118
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[]
}
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
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.
Upvotes: 1