Options based on generic parameters
At Splitgraph we recently had an interesting TypeScript type-related challenge. We wanted to add extra type checks on a function that returns results from our Data Delivery Network HTTP API. The returned data is the result of running an SQL query on Splitgraph. It comes in one of 2 shapes
- an array of arrays/tuples (when the API was provided an argument of
rowMode: "array"
) - an array of objects (when there was no
rowMode
parameter or it was set toobject
)
Just calling this function cannot infer what the actual objects/tuples will contain. It is up to the engineer writing that function call to provide that as a generic parameter.
The basic declaration of this function looked like:
declare function execute<RowType extends unknown[] | Record<string, unknown>>(
query: string,
options?: { rowMode?: "object" | "array" }
): Promise<RowType[]>;
There is a possible mismatch. There is nothing stopping us from accidentally
using a tuple RowType
without specifying rowMode: "array"
. The same problem
occurs when using an object as a RowType
but keeping rowMode: "array"
.
// ERROR: this will never be an array of tuples because
// of the missing `rowMode: "array"`
/** @type {Promise<[string, number][]>} */
const result1 = execute<[string, number]>("");
// ERROR: this will never be an array of objects because
// of the extra `rowMode: "array"`
/** @type {Promise<{ count: number }[]>} */
const result2 = execute<{ count: number }>("", { rowMode: "array" });
We can solve it in 2 ways:
- Function overloads
- Dynamically-determined
options
type
Function overloads
Function overloads is a feature of TypeScript that allows setting multiple function signatures for a given function.
Let's use it here.
declare function executeWithOverloads<RowType extends unknown[]>(
query: string,
options: { rowMode: "array" }
): Promise<RowType[]>;
declare function executeWithOverloads<RowType extends Record<string, unknown>>(
query: string,
options?: { rowMode?: "object" }
): Promise<RowType[]>;
Here we declare two overloads for the 2 distincs cases (an array vs an object as
the RowType
).
Let's check if that meets all our requirements:
import type { Equal, Expect } from "@type-challenges/utils";
const tupleCorrectRowMode = executeWithOverloads<[number, string]>("", {
rowMode: "array",
});
const objectCorrectRowMode = executeWithOverloads<{ count: number }>("", {
rowMode: "object",
});
const objectOmittedOptions = executeWithOverloads<{ count: number }>("");
const tupleInvalidRowMode = executeWithOverloads<[number, string]>("", {
// @ts-expect-error The row mode should be an array since the rows are tuples
rowMode: "object",
});
const tupleInvalidOmittedOptions =
// @ts-expect-error Options are required for tuple rows
executeWithOverloads<[number, string]>("");
const objectIncorrectRowMode = executeWithOverloads<{ count: number }>("", {
// @ts-expect-error The row mode should be an object since the rows are objects
rowMode: "array",
});
type cases = [
Expect<Equal<Awaited<typeof tupleCorrectRowMode>, [number, string][]>>,
Expect<Equal<Awaited<typeof objectCorrectRowMode>, { count: number }[]>>,
Expect<Equal<Awaited<typeof objectOmittedOptions>, { count: number }[]>>
];
Shout-out to
the type-challenges repository
for publishing the @type-challenges/utils
package and for being a great place
with TypeScript-related challenges.
Everything typechecks without any errors. See the TypeScript playground if you want to see it in action.
Dynamically-determined options
type
The other solution is more complex. It relies on using
conditional types
to determine the required type for options
.
type PossibleRowType = unknown[] | Record<string, unknown>;
type RowMode<RowType extends PossibleRowType> = RowType extends unknown[]
? "array"
: "object";
type ExecuteOptions<
RowType extends PossibleRowType,
ResolvedRowMode = RowMode<RowType>
> = ResolvedRowMode extends "object"
? // NOTE: return a tuple of arguments to be able to mark `options` as an optional argument
[options?: { rowMode: ResolvedRowMode }]
: [options: { rowMode: ResolvedRowMode }];
declare function executeWithTypeLogic<
RowType extends PossibleRowType,
Options extends ExecuteOptions<RowType> = ExecuteOptions<RowType>
>(query: string, ...options: Options): Promise<RowType[]>;
The fact that the options
parameter is optional adds a bit to the complexity.
Instead of just specifying the type for the options
parameter, we need to use
a 0-or-1 element tuple maybe containing the options
type and spread it inside
the execute
's parameters.
It also passes all the tests we defined in the previous section. See this TypeScript playground to play around with this solution.
Conclusion
I presented two ways that TypeScript offers to have a generic parameter influence the type of another parameter in a non-linear way. See this TypeScript playground to compare those approaches.
Overall, using function overloads turned out to be simpler in this case. It was
much easier to make options
optional in that case. Moreover, function
overloads should offer better documentation and error messages compared to
conditional types.