Home > Software engineering >  Is there a way to make these generic functions work in typescript?
Is there a way to make these generic functions work in typescript?

Time:01-07

Is there a way to use generics to type the following setValue and getValue functions correctly? I understand the error since it sees multiple types for the properties given. But is there a way to also make the functions aware of the name/age property type based on property value provided?

import { BehaviorSubject, Observable } from 'rxjs';

interface Person {
    age: BehaviorSubject<number>;
    name: BehaviorSubject<string>;
}

class SomeClass {
    private _somePerson:Person = {
        age: new BehaviorSubject(0),
        name: new BehaviorSubject('John')
    };

    public setValue<T>(inID: number, inProperty: keyof(Person), inValue: T): void {
        this._somePerson[inProperty].next(inValue);
    }

    public getValue<T>(inID: number, inProperty: keyof(Person)): Observable<T> {
        return this._somePerson[inProperty].asObservable();
    }

}

TS Playground Link

CodePudding user response:

Conceptually you want to express the operations as indexing with a generic key K (corresponding to inProperty) into some "base" Person type where the values are just string and number and not BehaviorSubject<string> and BehaviorSubject<number>. You could define BasePerson like

type BasePerson = {
    [K in keyof Person]: Person[K] extends BehaviorSubject<infer V> ? V : never
}

using conditional type inference to turn BehaviorSubject<V> into V, equivalent to this:

/* type BasePerson = {
  age: number;
  name: string;
}*/

And then the class would look like:

class SomeClass {
    private _somePerson: Person = {
        age: new BehaviorSubject(0),
        name: new BehaviorSubject('John')
    };

    public setValue<K extends keyof Person>(
        inID: number, inProperty: K, inValue: BasePerson[K]
    ): void {
        this._somePerson[inProperty].next(inValue); // error!
    }

    public getValue<K extends keyof Person>(
        inID: number, inProperty: K
    ): Observable<BasePerson[K]> {
        return this._somePerson[inProperty].asObservable(); // error!
    }
}

And that all works for the consumer of the class:

const sc = new SomeClass();
sc.setValue(123, "age", "oops"); // error
sc.setValue(123, "age", 123); // okay
sc.setValue(123, "name", 123); // error
sc.setValue(123, "name", "okay"); // okay
const obsNum = sc.getValue(123, "age"); 
// const obsNum: Observable<number>
const obsStr = sc.getValue(123, "name");
// const obsStr: Observable<string>

But, as you can see, the compiler is unable to follow the logic inside the implementations of setValue() and getValue(). In order to do so, it would need to see that BehaviorSubject<Person[K] extends BehaviorSubject<infer V> ? V : never> is the same type as Person[K] for generic K. This is unfortunately beyond the current capabilities of the compiler. It loses track of the correlation between BasePerson[K] and Person[K].

And so you get errors saying that what you're doing might not be safe. This is essentially the problem described in microsoft/TypeScript#30581 (that issue talks about correlated union types instead of generic types, but it's the same underlying problem).


If you don't care about having the compiler verify type safety in those implementations, you could just use type assertions to suppress the warnings:

public setValue<K extends keyof Person>(
    inID: number, inProperty: K, inValue: BasePerson[K]
): void {
    this._somePerson[inProperty].next(inValue as never); // okay
}

public getValue<K extends keyof Person>(
    inID: number, inProperty: K
): Observable<BasePerson[K]> {
    return this._somePerson[inProperty].asObservable() as any; // okay
}

That compiles with no error, but only because type assertions are shifting the burden of verifying type safety from the compiler to the developer.


If you do care about the compiler verifying type safety of those implementations, then you'll need to refactor as described in microsoft/TypeScript#47109. Instead of defining Person and computing BasePerson from it, you can define BasePerson direcly:

interface BasePerson {
    age: number;
    name: string;
}

And then Person can be expressed as a mapped type over its properties:

type Person = { [K in keyof BasePerson]:
    BehaviorSubject<BasePerson[K]>
}

This representation is very important, because now operations on Person[K] will stay generic. That is, the compiler can follow the correlation when indexing into a mapped type:

class SomeClass {
    private _somePerson: Person = {
        age: new BehaviorSubject(0),
        name: new BehaviorSubject('John')
    };

    public setValue<K extends keyof BasePerson>(
        inID: number, inProperty: K, inValue: BasePerson[K]
    ): void {
        this._somePerson[inProperty].next(inValue); // okay
    }

    public getValue<K extends keyof BasePerson>(
        inID: number, inProperty: K
    ): Observable<BasePerson[K]> {
        return this._somePerson[inProperty].asObservable(); // okay
    }
}

The compiler sees that this._somePerson[inProperty] is of type Person[K], which evaluates to BehaviorSubject<BasePerson[K]>, after which everything else just works.

Playground link to code

  • Related