billc.cn
billc.cn

Reputation: 7317

How to specify a type variable that depends on a constructor argument

A class like:

class Extractor<T, K extends keyof T> {
    constructor(private item: T, private key: K) { }

    extract(): T[K] {
        return this.item[this.key];
    }
}

is unnatural to use/extend, because the type parameter K takes a string literal type.

I want the type parameters to be <T, R> where R is effectively what T[K] previously computed to. However, TypeScript doesn't allow R to refer to a constructor argument, and the type expression for the key parameter is not allowed to refer to the parameter itself. Also, the constructor cannot have generic type parameters.

So where can I specify the constraint that R = T[typeof key]?

Upvotes: 0

Views: 868

Answers (1)

jcalz
jcalz

Reputation: 328473

I'm not exactly sure what you're trying to achieve, so I don't know which solution would work the best for you:


It is possible, using conditional types to calculate K from the types T and your desired R, but it might create more headaches than it solves:

type ValueOf<T> = T[keyof T];
type KeyofMatching<T, R extends ValueOf<T>> = 
  ValueOf<{ [K in keyof T]: T[K] extends R ? K : never }>;

The KeyofMatching type alias takes a type T and one of its property value types R, and returns all the keys K which return that type. So T[KeyofMatching<T, R>] is always R. Unfortunately, TypeScript is not smart enough to realize that, so if you have a value of the former type and want to return it as a value of the latter type, you have to use an assertion. Here's a function to do that:

function asR<T, R extends ValueOf<T>>(x: T[KeyofMatching<T, R>]): R {
  return x as any;
}

Now you can define your class:

class Extractor<T, R extends T[keyof T]> {
  constructor(private item: T, private key: KeyofMatching<T, R>) { }
  extract(): R {
    return asR(this.item[this.key]); // note the asR()
  }
}

This works as as far as it goes, but when you create a new Extractor, the compiler will really not be able to infer the narrow value for R you expect:

const extractorNotSoGood = new Extractor({a: "you", b: true}, "b");
// inferred as Extractor<{a: string, b: boolean}, string | boolean>;

If you want the narrowest possible R, you will have to explicitly specify it:

const e = new Extractor<{a: string, b: boolean}, boolean>({a: "you", b: true}, "b");

So, that works, but has some downsides.


Another way to attack this is to give up on using the constructor, and instead use a static builder method. Furthermore, since TypeScript doesn't understand that this.item[this.key] has type R, we can sidestep this by storing not a key but a function that uses the key. Like this:

class Extractor<T, R extends T[keyof T]> {
  constructor(private item: T, private extractorFunction: (x: T) => R) { }
  extract(): R {
    return this.extractorFunction(this.item);
  }
  static make<T, K extends keyof T>(item: T, key: K): Extractor<T, T[K]> {
    return new Extractor(item, i => i[key]);
  }
}

If you use make instead of the constructor, you get the behavior and inference you're looking for, I think:

const e = Extractor.make({ a: "you", b: true }, "b");
// e is an Extractor<{a: string, b: boolean}, boolean>

So, this works, and avoids the problems of the earlier method, but maybe adds some problems of its own. 🤷‍♀️


Okay, hope one of those helps you make progress. Good luck!

Upvotes: 2

Related Questions