React + TypeScript Polymorphic Hooks or Overloaded Functions Overload!
A fun problem that I ran into at work the other day. I find myself pretty frequently having the need to define a React hook multiple times, based on multiple invocation needs. In the past I've just defined multiple hooks with different names or used higher-order functions to emit different hooks based on an initial argument, but this time I decided to be a bit more clever. I wanted to come up with a pattern that allows for a hook to have 2 different defined behavior sets based on the arguments given to it. For at least some subset of that problem I think I've found what I consider to be a fairly elegant solution. The overall result is a single hook that can situationally accept 2 arguments or 3 arguments.
Some background points that are worth making w.r.t. the data shown here: The UserConfig
type defines a set of actions that are allowed for specific users. The OrgConfig
type defines a set of actions that are allowed through organization configuration. We can't make any inferences about keys that overlap, we have to assume that each value in the Config
objects exists independently of the others both within the type and analogously within the other Config
type. The only inference that we can make is that the OrgConfig
type has all values of AppPage
for its keys. I use react-query
and its useQuery
hook to represent data fetching, but the principles are the same for any React state management solution that doesn't necessarily emit a value right away.
type AppPage = "About Page" | "Manager Page" | "Home Page";
type UserConfig = {
["About Page"]: {
readonly: boolean;
edited: boolean;
};
["Home Page"]: {
count: boolean;
create: boolean;
};
};
type OrgConfig = {
["About Page"]: {
create: boolean;
};
["Manager Page"]: {
update: boolean;
read: boolean;
};
["Home Page"]: {
delete: boolean;
create: boolean;
};
};
Then the actual hook definition:
type ConfigType = "user" | "org";
type UseConfig = {
<TPage extends keyof UserConfig>(
type: "user, pageName: TPage, userId: string
): UserConfig[TPage];
<TPage extends keyof OrgConfig>(
type: "org, pageName: TPage, userId?: undefined
): OrgConfig[TPage];
};
const fetchUserConfig = (): Promise<UserConfig> => {...};
const fetchOrgConfig = (): Promise<OrgConfig> => {...};
const useConfig: UseConfig = (
configType: ConfigType,
pageName: AppPage,
userId?: string,
) => {
const {
data,
isError,
} = configType === "user"
? useQuery(["fetch-config", pageName, userId], fetchUserConfig)
: useQuery(["fetch-config", pageName], fetchOrgConfig);
useEffect(() => {
if (!isError) return;
displayErrorToast("Failed to fetch config");
}, [ isError ]);
return data;
};
Usage:
// Valid
const config = useConfig("user", "About Page", "testUserID01");
const { readonly } = useConfig("user", "About Page", "testUserID01");
const config = useConfig("org", "Manager Page");
const { read } = useConfig("org", "Manager Page");
// Invalid (Compiler Error)
const config = useConfig("user", "About Page"); // no userId!
const config = useConfig("org", "About Page", "testUserID01" // userId!
const { count } = useConfig("user", "About Page", "testUserID01");
const { create } = useConfig("user", "About Page", "testUserID01");
const { update } = useConfig("org", "About Page");
const { edited } = useConfig("org", "About Page");
There were a lot of important takeaways that I drew from putting together this pattern.
There are two ways you can define polymorphic/overloaded functions within TypeScript
// Method 1 (Multiple declarations, Single implementation):
function addTwo(x: number, y: number): number;
function addTwo(x: string, y: string): string;
function addTwo(x: number | string, y: number | string) {
// can narrow down types from here
}
// Method 2 (Defining an overload signature type):
type AddTwo = {
(x: number, y: number): number;
(x: string, y: string): string;
};
const addTwo: AddTwo = (x: number | string, y: number | string) => {...};
Overloaded functions within TypeScript must have the same amount of parameters...but no one said those parameters had to be defined
Specifically where we say userId?: undefined
. This allows us to have a definition that acts very much like a function that accepts one less parameter.
Overloaded function parameters can alter optional default behavior
Notice that userId
is an optional parameter unless we are specifically defining the version of useConfig
that needs to use a userId
.
Function generics work within overloaded functions.
We're able to type the actual Config
value object in our output because of the TPage
generic type that is driven by our function arguments. This is very similar to the definitions for identity
given within the TypeScript Docs, however we've slightly extended the notion to be used in an overloaded function definition.
Generics allow more specificity than a custom ValueOf<T>
type
In the past I've used a custom ValueOf<T> = T[keyof T]
function that is useful in some scenarios, but limited in that it creates a union of all the possible value types for a given type. In general if generics can be used that are given constrained types at compile-time, then we can avoid this issue and get much better certainty about the types of our values. This is especially useful if you are operating on keyed data that has no overlap in its value types.
And that's about it for this pattern! I hope that some of the ideas here are inspirational for others to write more strongly-typed React code with TypeScript and that we can all be one step closer to living in a world where all of the code is written in the compiler and no tricky users actually ever have to run it. But in all seriousness if anyone has any errata that they would like to bring up for the solution proposed or otherwise any questions (I know I didn't fully address typing the react-query
key for example) I would welcome any feedback in the comments.