Search code examples
typescript

Dynamic types challenge


We have a little ORM-like method that converts JSON to nested Postgres queries. Wondering how far we can take TypeScript to dynamically follow what is posted as a query.

This is how far I got:

export type SortDirection = string

export type OrderClause = Array<{
    [key: string]: SortDirection
}>

export type LegacyOrderClause = {
    [key: string]: SortDirection
}

export type QueryFilter = Record<string, any> | QueryFilter[]

export type QueryField = string | QueryFieldObject

export type QueryFieldObject = {
    name: string
    many?: boolean
    fields?: QueryField[]
    alias?: string
    filter?: QueryFilter
}

export interface Query {
    readonly table: string
    readonly alias?: string
    readonly fields: QueryField[]
    readonly limit?: number
    readonly offset?: number
    readonly filter?: QueryFilter
    readonly order?: OrderClause | LegacyOrderClause
}

export type ExtractTable<Q extends Query> = Q["table"]

export type QueryResult<Q extends Query> = {
    [K in ExtractTable<Q>]: any[]
}

const getTableRowsFromDb = async (query: Query) => {
    console.log("runs query and gets rows", query)
    return [] as any[]
}

class Orm {
    async query<Q extends Query>(query: Q): Promise<QueryResult<Q>> {
        return {
            [query.table]: await getTableRowsFromDb(query)
        } as QueryResult<Q>
    }
}

async function go() {
    const orm = new Orm()
    const results = await orm.query({
        table: "tasks",
        fields: [
            "id",
            "description",
            { name: "taskType", fields: ["id", "name"] },
            {
                name: "categories", fields: ["name"],
                many: true
            }]
    } as const)

    console.log(results.tasks) //works, but tasks is `any[]`
    console.log(results.taskss) //TS can infer the top level keys, so it errors correctly here
}

go()
    

Playground

However the type of tasks is any[]. In the example above, I want the result type to be:

{
   tasks: {
       id: any,
       description: any,
       taskType: {
          id: any,
          name: any
       },
       categories: {
           name: any
       }[]
    }[]
}
       

Few points on the DSL:

  • fields can be shorthand, directly referred to by name (e.g. "id", "description"), or longhand { name: "description" }.
  • fields can refer to related entity (many to one): ( { name: "taskType", fields: [ "name" ] })
  • fields can refer to related entity (many to many): ( { name: "categories", fields: ["name"], many: true })

The table comes through, but no fields are not typed. I have been trying to get the fields to show up as strongly typed.


Solution

  • The approach I'd take is to make QueryResult look like

    type QueryResult<Q extends Query> = {
        [K in ExtractTable<Q>]: FieldToData<Q["fields"][number]>[]
    }
    

    where FieldToData<F> takes a union of QueryField subtypes and converts them to the relevant data structure. Since the fields property of Q is an array type, we can index into it with number to get the union of the types of its elements (indexing into an array with a numeric key gives you an element).

    FieldToData<F> is defined like this:

    type FieldToData<F extends QueryField> = { [T in F as (
        T extends string ? T :
        T extends QueryFieldObject ? T["name"] :
        never
    )]:
        T extends string ? any :
        T extends QueryFieldObject ? OrArray<
            FieldToData<NonNullable<T["fields"]>[number]>, T["many"]
        > :
        never
    } & {}
    

    Here I'm using key remapping to iterate over the elements T of the union F and use the information in T to determine both the key of the resulting property (the stuff after as) and the value (the stuff after )]:).

    The key is a conditional type that checks whether T is a string (in which case it's the key) or a QueryFieldObject) (in which case its name` property is the key).

    The value is also a conditional type, but it's more complicated. If T is a string then the property type is just any. But if it's a QueryFieldObject then we need to recurse into its fields property and perform FieldToData on the elements of that array. That would look like FieldToData<T["fields"][number]>... except that fields is optional. You can't index into undefined with number, so I use the NonNullable utility type to make sure that T["fields"] is assignable to an array type. If fields isn't present, then you just get FieldToData<never> which is {}.

    Oh, but of course we need to output either that type or an array of that type, depending on the many property of T. That's what OrArray<T, B> does, which is defined as

    type OrArray<T, B> =
        B extends true ? T[] : T
    

    If B is true then you get an array of T, otherwise you get just T. If many is missing then it becomes unknown, and unknown extends true is false, so missing or false are treated the same.

    Finally, I intersect the whole thing with the empty object type {} because while this doesn't change the type at all, it prompts TypeScript to eagerly evaluate the type when displaying it, as opposed to leaving it in terms of FieldToData.


    The only change I would make after that is to make Q in query() a const type parameter so that when you call it with an object literal, TypeScript keeps track of the literal types of the strings you pass in without requiring the caller to use a const assertion:

    async query<const Q extends Query>(query: Q): Promise<QueryResult<Q>> {
        return {
            [query.table]: await getTableRowsFromDb(query)
        } as any as QueryResult<Q>
    }
    

    Okay, let's test it:

    async function go() {
        const orm = new Orm()
        const results = await orm.query({
            table: "tasks",
            fields: [
                "id",
                "description",
                { name: "taskType", fields: ["id", "name"] },
                { name: "categories", fields: ["name"], many: true }]
        })
    
        results.tasks;
        /* (property) tasks: {
            id: any;
            description: any;
            taskType: {
                name: any;
                id: any;
            };
            categories: {
                name: any;
            }[];
        }[] */
    }
    

    Looks good. You get the expected type for results.tasks.

    Playground link to code