Nandin Borjigin
Nandin Borjigin

Reputation: 2154

How to declare a generic interface in typescript that uses generic argument as property name?

I want to declare some interface like this:

interface Foo<K extends string> {
    [k in K]: string // TS error: A computed property name in an interface must refer to an expression whose type is a literal type or a 'unique symbol' type.
}

Usage:

let b: Foo<'bar'> = { bar: '123' }
let c: Foo<'a' | 'b'> = { a: '123', b: '321' }

Is that possible ?


Update:

I can't just use Record utility generic or type aliases because I actually want to use polymorphic this type.

interface Foo<K extends string> {
    [k in K] (): this // 
}

class Bar implements Foo<'sorted' | 'reversed'> { /* omitted */ }

let foo: Bar
foo.sorted() // returns Bar
foo.reversed().sorted().sorted().reversed() // still Bar

Upvotes: 3

Views: 1771

Answers (2)

Nandin Borjigin
Nandin Borjigin

Reputation: 2154

@Titian Cernicova-Dragomir 's answer is correct for the original question, while the actual underlying problem of mine was more complicated then the question itself. I'm posting my solution here just in case some other folks reaches here with similar needs.

The actual problem was: Say I have a class TClass and a function addMethod, which adds some method to the TClass.prototype. In other words, addMethod works as a decorator but I omitted the @ decorator syntax since it cannot change, at least for now, the signature of the class being decorated (See Github issue that tracks this feature).

If addMethod is to be called for only once, the problem is easily solvable by writing a recursive type alias:

type Ctor<T, Args extends any[] = any[]> = new (...args: Args) => T
type CtorParameters<T extends Ctor<any>> = T extends Ctor<any, infer P> ? P : never

type Augmented<T, K extends string, Args extends any[]> = T & { [k in K]: (...args: Args) => Augmented<T, K, Args> }

function addMethod<
    T extends Ctor<any>,
    K extends string,
    Args extends any[]
> (ctor: T, k: K, method: (this: InstanceType<T>, ...args: Args) => void): Ctor<Augmented<InstanceType<T>, K, Args>, CtorParameters<T>> {
    ctor.prototype[k] = function (...args: any[]) {
        method.apply(this, args)
        return this
    }
    return ctor
}

class Foo {
    constructor(public foo: number) {}
}

const Bar = addMethod(Foo, 'inc', function (delta: number)  {
    this.foo += delta
})

const bar = new Bar(1)
bar.inc(2)
console.log(bar.foo) // 3
bar.inc(3).inc(4)
console.log(bar.foo) // 10

However, if addMethod is called multiple times subsequently, the instance of resulting constructor is somehow compromised. Added methods can be fully chained only if they were called in certain order.


const Baz = addMethod(Bar, 'mult', function (factor: number) {
    this.foo *= factor
})

const baz = new Baz(2)

baz.mult(3).inc(4)
console.log(baz.foo) // 10, works

baz.inc(3).mult(4) // Property 'mult' does not exist on type 'Augmented<Foo, "inc", [number]>'.

This is why I posted the original question. I realized that using a recursive type alias to express the augmented type does not works since the return type of methods that are added earlier does not include the methods that are added later. Then I thought declaring the return type as this would help. But this is only supported inside class or interface. Hence the question.

However, I also realized that addMethod does not need to be called multiple times as long as I can provide the methods in one call.


type Augmented<T, Methods extends Record<string, (...args: any[]) => void>> = 
    T & { [k in keyof Methods]: (...args: Parameters<Methods[k]>) => Augmented<T, Methods> }

function addMethod<
    T extends Ctor<any>,
    Methods extends Record<string, (...args: any[]) => void>
> (ctor: T, methods: Methods & ThisType<InstanceType<T>>): Ctor<Augmented<InstanceType<T>, Methods>> {
    const keys = Object.keys(methods) as (keyof Methods)[]
    keys.forEach(k => {
        ctor.prototype[k] = function (...args: any[]) {
            methods[k].apply(this, args)
            return this
        }
    })
    return ctor
}


const Baz = addMethod(Foo, {
    inc: function (delta: number) {
        this.foo += delta
    },
    mult: function (factor: number) {
        this.foo *= factor
    }
})

const baz = new Baz(2)

baz.mult(3).inc(4)
console.log(baz.foo) // 10, works

baz.foo = 2
baz.inc(3).mult(4) 
console.log(baz.foo) // 20, works

Upvotes: 0

Titian Cernicova-Dragomir
Titian Cernicova-Dragomir

Reputation: 249466

Interfaces can't contain mapped types, only type aliases can:

type Foo<K extends string> = {
    [k in K]: string 
}


let b: Foo<'bar'> = { bar: '123' }
let c: Foo<'a' | 'b'> = { a: '123', b: '321' }

Note that this type is very similar with Record, you might just want to use that. This is equivalent to the explicit mapped type:

type Foo<K extends string> = Record<K, string>

Upvotes: 4

Related Questions