Home > Mobile >  Need a more performant alternative to large TypeScript conditional type which takes a string union g
Need a more performant alternative to large TypeScript conditional type which takes a string union g

Time:01-20

Problem summary:

  • I'm running into huge performance issues with TypeScript when using a conditional type that has 100s of nested ternary conditions.
  • My conditional type takes a generic argument that is a string union type, containing all the names of my SQL tables, e.g. public.user
  • And the conditional type returns back a string union type of all of the column names for that given table (the generic argument). e.g. 'id' | 'username'

In the example below, imagine we've got a system with 2 SQL tables (in reality there's 100s of tables SQL VIEWs, likely growing to 1000s in the future):

  • public.user - columns: id, username
  • public.department - columns: id, department_name

Here's a simplified example of the code I have already:

/**
 * Code below is auto-generated, in reality there are literally 100s of tables, and 1000s of columns.
 */
type AnyTableName = 'public.user' | 'public.department';
type ColumnsForUser = 'id' | 'username';
type ColumnsForDepartment = 'id' | 'department_name';

/**
 * This is the slow conditional type that I want to replace with something more performant:
 */
export type Convert_TableName_to_ColumnName<T extends AnyTableName> =
    T extends 'public.user'       ? ColumnsForUser
  : T extends 'public.department' ? ColumnsForDepartment
  : never;

/**
 * ...end of auto-generated code
 */


/**
 * Simple examples of functions where I use this stuff
 * Functions aren't code generated, and I want to avoid having to do any code generation for the actual functions that consume the types above.
 */
function define_sql_index<TableNameGeneric extends AnyTableName>(table_name: TableNameGeneric, columns_array: Convert_TableName_to_ColumnName<TableNameGeneric>[]) {}

define_sql_index('public.user', ['username']);

function pick_some_columns_using_required_bools<TableNameGeneric extends AnyTableName>(table_name: TableNameGeneric, bools_record: Record<Convert_TableName_to_ColumnName<TableNameGeneric>, boolean>) {}

pick_some_columns_using_required_bools('public.department', {
    id: false,
    department_name: true,
});

Problems it's causing:

  • Massively slowing down my editor (in both vscode JetBrains IDEs)
  • Using a lot of CPU
  • Also now occasionally running into error TS2321: Excessive stack depth comparing types

Question:

  • Is there some more performant alternative to my Convert_TableName_to_ColumnName conditional type?

I know that if the function arguments are structured differently, and include both the table name its columns within the same object, I can just code-generate a discriminated union type like:

type AnyTableWithColumns =
    | {
          table: 'public.user';
          columns_array: ('id' | 'username')[];
      }
    | {
          table: 'public.department';
          columns_array: ('id' | 'department_name')[];
      };

...and I am actually doing this already. But there's many other functions / places in the codebase where the table name is passed in separately to the list of columns, and I want to use a generic to convert: table name -> column names ...without them both having to be in the same object.

Maybe there's something similar to function overloading? (but instead for types), where I could just code generate a "type overload" for each table or something?

Basically I can do code-generation for all the tables their column names in the form of generating some metadata and types. But I don't want to have to also code-generate all my actual functions such as define_sql_index() pick_some_columns_using_required_bools() (or have function overloads for them all) - because there's actually a lot of them, and they're more complicated than the examples above. For many of them they take a giant config object argument, and the table name column names aren't always "close to each other" in these big config objects.

CodePudding user response:

A very simple approach consists in using a "dictionary of types", i.e. a type alias or an interface which keys are your table names, and their value is your corresponding union of column names:

type TableNameToColumns = {
    // table_name: union of column names
    'public.user': 'id' | 'username';
    'public.department': 'id' | 'department_name'
}

Then your generic table name now extends the keys of that type dictionary, and you get the associated column names union by simply using an indexed access type:

function define_sql_index<
    // generic type argument extends the keys of the dictionary
    TableNameGeneric extends keyof TableNameToColumns
>(
    table_name: TableNameGeneric,
    // Correponding columns with indexed access type
    columns_array: TableNameToColumns[TableNameGeneric][]
) { }

define_sql_index('public.user', ['username']); // Okay
define_sql_index('public.user', ['username', 'department_name']); // Error: Type '"department_name"' is not assignable to type '"id" | "username"'.
//                                            ~~~~~~~~~~~~~~~~


function pick_some_columns_using_required_bools<
    TableNameGeneric extends keyof TableNameToColumns
>(
    table_name: TableNameGeneric,
    bools_record: Record<
        TableNameToColumns[TableNameGeneric],
        boolean
    >
) { }

pick_some_columns_using_required_bools('public.department', {
    id: false,
    department_name: true,
}); // Okay

pick_some_columns_using_required_bools('public.department', {
    id: false,
    department_name: true,
    username: false // Error: Object literal may only specify known properties, and 'username' does not exist in type 'Record<"id" | "department_name", boolean>'.
    //~~~~~~~~~~~~~
});

Playground Link

Such type dictionary should be much easier to handle by IDE's, since they just need a straightforward lookup to get the associated value (column names union), instead of having to execute the chained conditionals.

The syntax and readability is also greatly improved.


You could even facilitate the transition from your current code by deriving your current types from the type dictionary:

type AnyTableName = keyof TableNameToColumns;
type ColumnsForUser = TableNameToColumns['public.user'];
type ColumnsForDepartment = TableNameToColumns['public.department'];

export type Convert_TableName_to_ColumnName<T extends AnyTableName> = TableNameToColumns[T];

// With this, your sample functions work as is with NO modification at all!

Playground Link


By using an interface instead of a type alias, you could also leverage interface declaration merging to split your dictionary, and potentially collocate each table name/columns type with their actual table code, if any:

// public.user table code
interface TableNameToColumns {
    // table_name: union of column names
    'public.user': 'id' | 'username';
}

// public.department table code
interface TableNameToColumns {
    'public.department': 'id' | 'department_name'
}

Playground Link

  • Related