In Typescript "keyof Record<string, string>" is not "string"
Asked Answered
O

3

7

I'm hitting this wall since days now and I'm not sure anymore if I'm the problem or if typescript is broken...

I defined a generic class with the generic extending Record<string, string>:

class DbTable<ColumnDefinitions extends Record<string, string>> { /* ... */ }

At a later point I want to use the exact keys (e.g. 'id' | 'name' instead of just string), so I use the keyof operator on the generic, like this:

function selectFields(fields: (keyof ColumnDefinitions)[]) { /* ... * / }

But keyof ColumnDefinitions resolves to string | number | symbol instead of just being of type string.

What did I miss?!

Here is a TS playground link with an example.

Osteoclasis answered 7/5, 2022 at 7:0 Comment(0)
A
15

You can extract only strings from string | number | symbol in the method signature.

Instead of

fields: (keyof T)[]

you can write

fields: (Extract<keyof T, string>)[]

and the error goes away.

TypeScript Playground

Abate answered 7/5, 2022 at 7:44 Comment(0)
R
1

To my understanding, the built-in generic Record<T> adds on the symbol and number type. If you think about what happens in Javascript, which is what typescript compiles to, this makes sense, because you can index with number, string, or symbols.

Ex:

const map = {0: "hello"}
//both are valid
map[0] 
map["0"]

A simple type check to ensure fieldNames is equal to string should resolve your problem at the end of your sandbox.

 fields: fields.map(fieldName => {
  if(typeof fieldName === "string") return new DbField(fieldName)
  else return {}
}

Or you can do the following to ensure conversion into string:

    fields: fields.map(fieldName => new DbField(fieldName.toString())
Remindful answered 7/5, 2022 at 7:14 Comment(1)
Thank you! This is a valuable straight-forward solution. I prefer the other solution because it better separates the static TS type checking and the JS runtime code.Osteoclasis
C
1

It's possible to 'keep hold' of the definition of Column through a generic definition of Row. This creates type from which Column can be inferred and is as narrow as the generic passed to Row. The below example has no compile errors (not tested for behaviour, though).

Contrast the original playground and the playground with generic inference.

type Row<Col extends string> = Record<Col, string>
type Column<R extends Row<string>> = R extends Row<infer Col> ? Col : never ;

// for debugging
type Inferred = Column<Row<string>>
//  ^? type Inferred = string

abstract class DbTable<R extends Row<string>> {
  protected constructor(
    public readonly tableName: string,
    public readonly columnNames: Record<Column<R>, string>
  ) {}
}

class DbField<FieldName extends string> {
  constructor(public name: FieldName, public alias?: string) {}
}

class UserTable extends DbTable<{ id: string, name: string }> {
  public constructor(){
    super('user', { id: 'TEXT PRIMARY KEY NOT NULL', name: 'TEXT NOT NULL'})
  }
}

function buildQuery<R extends Row<any>>(table: DbTable<R>, fields: Column<R>[]) {
  return {
    table,
    fields: fields.map(fieldName => new DbField(fieldName))
  }
}

Calathus answered 23/7, 2023 at 18:17 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.