Search code examples
typescriptgenericsmapped-types

TypeScript: Using Mapped Types with Generics


I've been experimenting with TypeScript with the hope of making a type-safe database query library (loosely based on Scala's Slick). I've made some good progress with the help of Mapped Types but I'm getting stuck on preserving the underlying type of the Column. See the code below for a comprehensive example:

class Table {}

class TableExpression<TK extends Table> {
  table: TK
  alias: string
  constructor(table: TK, alias?: string) {
    this.table = table
    this.alias = alias || table.constructor.name
  }
}

class Column<T> {
  name: string
  defaultValue?: T
}

class StringC extends Column<string> {
  constructor(name: string, defaultValue?: string) {
    super()
    this.name = name
    this.defaultValue = defaultValue
  }
}

class ColumnExpression<TK extends Table, CK extends Column<any>> {
  table: TableExpression<TK>
  column: CK
  alias: string
  constructor(table: TableExpression<TK>, column: CK, alias?: string) {
    this.table = table
    this.column = column
    this.alias = alias || column.name
  }

  eq(val: any): string {
    return `${this.table.alias}.${this.column.name} = "${val}"` // Obviously, not safe.
  }
}

class E1 extends Table {
  name = new StringC('name')
  slug = new StringC('slug')
}

let e1 = new E1()
let ee1 = new TableExpression(e1, 'e1')

type TableQuery<TK extends Table> = {
  [P in keyof TK]: ColumnExpression<TK, TK[P]>
}

function query<TK extends Table>(te: TableExpression<TK>): TableQuery<TK> {
  let result = {} as TableQuery<TK>
  for (const k in te.table) {
    result[k] = new ColumnExpression(te, te.table[k])
  }
  return result
}

let tq1 = query(ee1)
console.log(tq1.name.eq('Pancakes')) // e1.name = "Pancakes"

This code compiles and works as expected. What I'm getting stuck on is how to make the eq() method leverage the generic type used by Column. I can easily extend ColumnExpression to use another type parameter, such as CT like below:

class ColumnExpression<TK extends Table, CT, CK extends Column<CT>> {
...
  eq(val: CT) { ... }
}

That part makes sense. The problem then is carrying that forward into the Mapped Type definition for TableQuery. It seems TableQuery needs to be parameterized in some way that I can pass in a CT to the ColumnExpression but I can't figure out how to do that. Any ideas?


Solution

  • You can use Lookup Types.

    To get the generic type as parameter value in eq change your signature like so:

    eq(val: CK['defaultValue']): string
    

    For Column<string> this will result in string | undefined, at least with --strictNullChecks enabled. That's because defaultValue is optional. So calling eq(undefined) would still be valid.

    You could remove the undefined by doing CK['defaultValue'] & Object but I am not sure if this has any side effects that I am not aware of.