RSS feed iconGitHub logo

Options based on generic parameters

2022-07-035 minutes readTypeScript

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 to object)

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:

  1. Function overloads
  2. 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.