Typescript method return type based on attributes

644 Views Asked by At

Typescript's conditional : T extends U ? X : Y syntax is pretty powerful, but I have not found a way to specify the type of a return based on an attribute of a class, or something in the function itself. For instance, take this simple class:

class X {
    members: number[] = [];
    returnRoman = false;

    first(): number|string {
        if (!this.returnRoman) {
            return members[0];
        } else {
            return numberToRomanNumeral(members[0]);
        }
    }
}

Is there a way to narrow the type returned to simply number or string for type-checking? The particular issue I have is that I have a structure like this:

class Base {
    x: number = 0;
}

class Sub extends Base { 
    y: number = 0;
}

class FilterIterator {
    members: Base[] = [];
    filterClass: typeof Base = undefined;

    class first(): Base {
        for (const m of this.members) {
            if (this.filterClass !== undefined && !(m instanceof this.filterClass)) {
                continue;
            }
            return m;
        }
    }
}

const fi = FilterIterator();
fi.filterClass = Sub
fi.members = [new Sub(), new Sub()];

const s = fi.first();
if (s.y > 0) {
   console.log('s is moving up in the world!');
}

Because fi.first() is typed to return Base, the line if (s.y > 0) raises errors because y does not exist on Base. In this case, it wouldn't be too hard to do:

const s = fi.first() as Sub;

but if we had something like this instead:

class FilterIterator extends Base {
    ...
    filterClass = FilterIterator;  // I know, not possible simply, but there are ways...
}

then you end up with code such as:

const fi = FilterIterator();
[ set up .members with nested FilterIterators ]
const firstOfFirstOfFirst = fi.first().first().first();

being rewritten as:

const firstOfFirstOfFirst = (((fi.first() as FilterIterator).first() 
    as FilterIterator).first() as FilterIterator);

and it gets even worse if we actually create generators that can return generators, etc. since declaring types within for...of loops is not yet possible. It also moves the logic of determining what the value returned should be to the consuming software where FilterIterator or Base itself should know what classes might be returned.

Any solutions or improvements would be welcome.

1

There are 1 best solutions below

1
On BEST ANSWER

Your simplified example is harder to type properly because returnRoman is an instance variable that can presumable change its value multiple times within the same instance. You would have to update it via a setter and use the syntax asserts this is this & SomeType.

Your actual use case could potentially run into the same difficulties of needing to convert a type after it has already been set, but we can circumvent those issues by taking the filterClass as an argument in the constructor. This also means that the type of the filterClass can always be properly inferred for an instance because it is known from the moment that the instance is created. So it becomes a straight-forward use case for generic classes.

type Constructor<T> = new (...args: any[]) => T;

class FilterIterator<T> {
    members: (T | Base)[] = [];
    private filterClass: Constructor<T>;

    constructor( filterClass: Constructor<T> ) {
        this.filterClass = filterClass;
    }

    first(): T {
        for (const m of this.members) {
            if (m instanceof this.filterClass) {
                return m;
            }
        }
        // you need to either throw an error or expand your return type to include undefined
        throw new Error("no match found");
    }
}

instanceof is an automatic type guard in typescript, so if you hit the line with return m typescript knows that m is guaranteed to be the same type T of your filterClass.

I think you are trying to do something a little different, where the filter is some sort of custom check rather than a class constructor, but I don't have enough information to really type that properly. You would define your filter as a user-defined type guard.

type Filter<T> = (value: any) => value is T;

class CustomFilterIterator<T> {
    members: any[] = [];
    private filter: Filter<T>;

    constructor( filter: Filter<T> ) {
        this.filter = filter;
    }

    first(): T | undefined {
        for (const m of this.members) {
            if (this.filter(m)) {
                return m;
            }
        }
        return;
    }
}

const isNumber = (value: any): value is number => {
    return typeof value === "number"
}

const numberFilter = new CustomFilterIterator(isNumber);
numberFilter.members = [55, "55", "hello", 78.3];
numberFilter.first()?.toExponential();

Typescript Playground Link