Krzyrok
Krzyrok

Reputation: 180

Can I use type as value (or correctly infer generic class type from constructor parameter)?

I have generic class which accepts another type and the key name from this type. I want to also initialise property with this key name. I've defined this in TS but I have to explicitly pass the name of the key as generic param and the same same value to constructor. It looks a little unnecessary - these values are always the same (type and value).

Is it possible to infer type for generic class from its constructor parameter? Or use generic "type" as "value" (probably not - I've got error: 'ValueKey' only refers to a type, but is being used as a value here.ts(2693)).

Code with definition:

interface InterfaceWithProperties {
    stringProperty: string;
    numberProperty: number;
}

class GenericCLass<TypeWithKeys, ExactKey extends keyof TypeWithKeys> {
    keyNameFromAnotherType: ExactKey; // this property should have value equal to key from another type;

    // is it a way to remove this parameter? it's exactly duplicated with ExactKey
    // or to detect correctly ExactKey type based on `property` value
    constructor(keyNameFromAnotherType: ExactKey) {
        // this.property = ExactKey; // ofc: not working code
        this.keyNameFromAnotherType = keyNameFromAnotherType;
    }
}

current usage:

const test1 = new GenericCLass<InterfaceWithProperties, 'stringProperty'>('stringProperty');

I want the same result but without this extra parameter. Something like this:

const test2 = new GenericCLass<InterfaceWithProperties, 'stringProperty'>();

or smth like this:

const test3 = new GenericCLass<InterfaceWithProperties>('stringProperty');

TS playground with this code: link

Upvotes: 4

Views: 2884

Answers (2)

Titian Cernicova-Dragomir
Titian Cernicova-Dragomir

Reputation: 250366

The bad new is you can't do this easily. The best way to do it would to let the compiler infer ExactKey based on constructor args. This can be done only if we let typescript infer all type parameters. But you need to specify one type parameter and infer the other and this is not possible as there is no support for partial inference (at least as of 3.5, there was a plan to add it, but it was pushed back, and then removed from the roadmap).

One option would be to use function currying with a static function instead of the constructor:

interface InterfaceWithProperties {
    stringProperty: string;
    numberProperty: number;
}

class GenericCLass<TypeWithKeys, ExactKey extends keyof TypeWithKeys> {
    keyNameFromAnotherType: ExactKey; 
    private constructor(keyNameFromAnotherType: ExactKey) {
        this.keyNameFromAnotherType = keyNameFromAnotherType;
    }
    static new<T>(){
        return function <ExactKey extends keyof T>(keyNameFromAnotherType: ExactKey) {
            return new GenericCLass<T, ExactKey>(keyNameFromAnotherType);
        }         
    } 
}


const test1 = GenericCLass.new<InterfaceWithProperties>()('stringProperty')

Another option would be to give the compiler an inference site for TypeWithKeys in the constructor args:

interface InterfaceWithProperties {
    stringProperty: string;
    numberProperty: number;
}

class GenericCLass<TypeWithKeys, ExactKey extends keyof TypeWithKeys> {
    keyNameFromAnotherType: ExactKey; 
    constructor(t: TypeWithKeys, keyNameFromAnotherType: ExactKey) {
        this.keyNameFromAnotherType = keyNameFromAnotherType;
    }
}


const test1 = new GenericCLass(null as InterfaceWithProperties, 'stringProperty')

Or if null as InterfaceWithProperties scares you, you could use a Type class:

class Type<T> { private t:T }
class GenericCLass<TypeWithKeys, ExactKey extends keyof TypeWithKeys> {
    keyNameFromAnotherType: ExactKey; 
    constructor(t: Type<TypeWithKeys>, keyNameFromAnotherType: ExactKey) {
        this.keyNameFromAnotherType = keyNameFromAnotherType;
    }
}


const test1 = new GenericCLass(new Type<InterfaceWithProperties>(), 'stringProperty')

Neither solution is great, neither is particularly intuitive, partial argument inference would be the best solution.

Upvotes: 3

jcalz
jcalz

Reputation: 330456

The problem is that you want to manually specify one type parameter and have the compiler infer the other one. That's called partial type argument inference and TypeScript doesn't have it (as of TS3.4). You can either manually specify all type parameters (which you don't want to do), or have all type parameters inferred by the compiler (which you can't do because there's nothing from which you can infer the specified type).

There are two main workarounds for this situation:

The first is to rely entirely on type inference and use a dummy parameter to infer the type you would normally specify. For example:

class GenericCLass<T, K extends keyof T> {
    keyNameFromAnotherType: K; 

    // add dummy parameter
    constructor(dummy: T, keyNameFromAnotherType: K) {
        this.keyNameFromAnotherType = keyNameFromAnotherType;
    }
}

const test = new GenericCLass(null! as InterfaceWithProperties, 'stringProperty');
// inferred as GenericCLass<InterfaceWithProperties, "stringProperty">

You can see that the value passed in as the first parameter is just null at runtime, and the constructor doesn't look at it at runtime anyway. But the type system has been told that is is of type InterfaceWithProperties, which is enough for the type to be inferred the way you want.

The other workaround is to break up anything that would normally use partial inference into two pieces via currying; the first generic function will let you specify one parameter, and it returns a generic function (or a generic constructor in this case) that infers the other parameter. For example

// unchanged
class GenericCLass<T, K extends keyof T> {
    keyNameFromAnotherType: K;

    constructor(keyNameFromAnotherType: K) {
        this.keyNameFromAnotherType = keyNameFromAnotherType;
    }
}

// curried helper function
const genericClassMaker =
    <T>(): (new <K extends keyof T>(
        keyNameFromAnotherType: K
    ) => GenericCLass<T, K>) =>
        GenericCLass;

// specify the one param
const InterfaceWithPropertiesGenericClass =
    genericClassMaker<InterfaceWithProperties>();

// infer the other param
const test = new InterfaceWithPropertiesGenericClass('stringProperty');
// inferred as GenericCLass<InterfaceWithProperties, "stringProperty">

That leaves your class definition alone but creates a new helper function which returns a partially specified version of the GenericClass constructor for you to use. You can do it in one shot but it's ugly:

const test = new (genericClassMaker<InterfaceWithProperties>())('stringProperty');
// inferred as GenericCLass<InterfaceWithProperties, "stringProperty">

Anyway, hope one of those works for you. Good luck!

Upvotes: 3

Related Questions