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()
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:
"id"
, "description"
), or longhand { name: "description" }
.{ name: "taskType", fields: [ "name" ] }
){ 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.
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
.