TypeScript provide (unspecified) generic type as generic argument

Viewed 344

I'm looking to provide a generic type as a type argument without resolving it to a concrete type first. Put another way, I'm looking for a way to specify a type mapping function that I can use when inheriting from a base class.

Example (incorrect) syntax which hopefully explains better than I can:

abstract class RunIt<SomeMagicConverter> {
    // The return type for this function depends on the type of the
    // argument *and* on the way the implementation class was declared:
    run<T>(o: T): SomeMagicConverter<T> { // (syntax error)
        return this.handle(o); // (imagine this does something more interesting)
    }
    protected abstract handle<T>(o: T): SomeMagicConverter<T>; // (syntax error)
}

type MyMagicConverter<T> = TypeIWantToReturn<T>; // MyMagicConverter is generic
class MyRunIt extends RunIt<MyMagicConverter> { // but we don't specify T here
    // [...]
}
new MyRunIt().run(7); // call infers T is number, so returns TypeIWantToReturn<number>
new MyRunIt().run(''); // now T is string, so returns TypeIWantToReturn<string>

Additionally, I want to restrict this so that SomeMagicConverter<T> extends SomeBase<T> is guaranteed. i.e.

abstract class RunIt<SomeMagicConverter extends SomeBase>

For a more concrete example of how I hope to use this, here's a basic base-class for a wrap-with-caching (not quite my actual use-case but demonstrates the need):

interface Wrapped<T> {
    contains(other: T): boolean;
}

abstract class Store {
    private readonly cached = new Map<any, any>();

    protected abstract applyWrap<T>(o: T): Wrapped<T>;

    wrap<T>(o: T): Wrapped<T> { // <-- this should return something more specific
        if (!this.cached.has(o)) {
            this.cached.set(o, this.applyWrap(o));
        }
        return this.cached.get(o);
    }
}

class Foo<T> implements Wrapped<T> {
    constructor(private readonly o: T) {}
    contains(other: T): boolean { return other === this.o; }
    extraFooFunc(): void {}
}

class FooWrapper extends Store {
    constructor() { super(); }
    protected applyWrap<T>(o: T): Foo<T> { return new Foo<T>(o); }
}

new FooWrapper().wrap(4).extraFooFunc(); // syntax error because extraFooFunc is not defined on Wrapped

Clearly I can work around this by defining wrapping methods, but I'd like to avoid having to do that on every child class:

class FooWrapper extends Store {
    // [...]
    wrap<T>(o: T): Foo<T> { return super.wrap(o) as Foo<T>; }
}
1 Answers

Generic generic parameters are not supported yet (March 2021). See TypeScript Issue and a similar SO question for more information.

However, what you describe can be implemented using Indexed Access Types and Mapped Types:

// Any Converter to be used with RunIt must be added to Converters
interface Converters<T> { }

// All Converters must implement this interface
interface ConverterBase<T> { length: number }

// Maps valid keys to themselves, invalid ones to `never`
type ValidKey<T, K extends keyof Converters<T>> =
    Converters<T>[K] extends ConverterBase<T> ? K : never;

// Contains all entries from Converters where the type extends ConverterBase
type ConstrainedConverters<T> = {
    [K in keyof Converters<T> as ValidKey<T, K>]: Converters<T>[K];
};

abstract class RunIt<K extends keyof ConstrainedConverters<void>> {
    run<T>(o: T): ConstrainedConverters<T>[K] {
        return this.handle(o);
    }
    protected abstract handle<T>(o: T): ConstrainedConverters<T>[K];
}

// Re-open Converters and add some Converters
interface Converters<T> { Id: T, Array: Array<T> }

class ArrayRunIt extends RunIt<'Array'> {
    protected handle<T>(o: T) { return [o]; }
}

// @ts-expect-error Type '"Id"' does not satisfy the constraint '"Array"'.(2344)
class IdRunIt extends RunIt<'Id'> {
    protected handle<T>(o: T) { return o; }
}
Related