Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Request: Allow abstract classes to implement MappedTypes that are instantiated with type parameters. #21326

Open
CyrusNajmabadi opened this issue Jan 21, 2018 · 9 comments
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript

Comments

@CyrusNajmabadi
Copy link
Contributor

Ok, so the title is a mouthful, but here's an example of what we're trying to accomplish:

interface DeferredProperty<T> {
    get(): T;
}

type Deferred<T> = {
    [P in keyof T]: DeferredProperty<T[P]>
};

Here we use mapped types to express the idea that a interface type might be wrapped with a new type that exposes the same properties as it, except as functions instead of data properties. So, for example:

interface Person {
    age: number,
    name: string
}
var deferredPerson: Deferred<Person>;
var age = deferredPerson.age.get();


interface Vehicle {
    numWheels: number,
    cost: number
}
var deferredVehicle: Deferred<Vehicle>;
var cost = deferredVehicle.cost.get();

We'd like to make a lot of our types deferable in our system, so we create a simple abstract base class that will help us out by doing some of the work for us. In other words, we'd like to be able to write:

abstract class BaseDeferred<T> implements Deferred<T> { //<-- note: currently not legal
    // some common stuff, including concreate methods

    // Maybe some abstract methods.
    // protected abstract whatever(): void;

    // etc.
}

We could then do the following:

class DeferredPerson extends BaseDeferred<Person> {
}
class DeferredVehicle extends BaseDeferred<Vehicle> {
}

At this point TypeScript would say: "Hey, DeferredPerson doesn't properly implement BaseDeferred<Person>, it is missing age: DeferredProperty<number> and name: DeferredProperty<string>. (And likewise for DeferredVehicle)

However, this isn't currently allowed as we cannot say: abstract class BaseDeferred<T> implements Deferred<T>

This is a somewhat understandable restriction. After all, how can the compiler actually validate that BaseDeferred<T> is implementing Deferred<T> when it cannot know (at this point) how the Deferred<T> lookup type will expand.

While understandable, it would be nice if this restriction could potentially be lifted for abstract types. Because the type is abstract, we would like it if the check was only actually done at the time the type was concretely derived from. So, for example, when someone wrote:

class DeferredPerson extends BaseDeferred<Person> {
	// Now the compiler create the full type signature for BaseDeferred<Person> and then checked DeferredPerson against it.
}

--

The workaround today is to do the following:

abstract class BaseDeferred<T> { // <-- note: no implements
}

class DeferredPerson extends BaseDeferred<Person> implements Deferred<Person> {
}
class DeferredVehicle extends BaseDeferred<Vehicle> implements Deferred<Vehicle> {
}

The compiler now appropriately does the right checks. This is unpleasant though as it's a very simple thing to miss. Because all subclasses must implement this type, we would very much like to push that requirement up to the base class and have the enforcement applied uniformly across all subtypes.

--

Thanks much, and i hope everyone is doing great! We're having a blast with TS, especially (ab)using the type system to express some very interesting things. The more crazy stuff that can be expressed (especially around constraints and variadic types) the happier we are 🙂

@CyrusNajmabadi
Copy link
Contributor Author

Tagging @DanielRosenwasser .

The reason for this (afaict) is that TS requires the implements/extends clauses to only allow interfaces (reasonable). And TS treats SomeMappedType<SomeType> as an interface if the mapped type has been fully instantiated (i.e. it has been expanded such that it doesn't contain the [p in keyof T] member.

A shorter way of describing the request would be: allow abstract classes to contain this mapped type member, and then actually resolve this member at the concrete subclass point instead of eagerly.

@RyanCavanaugh
Copy link
Member

The usual advice here is to declaration-merge, e.g.

class BaseDeferred<T> {
}
interface BaseDeferred<T> extends Deferred<T> { }

Does that give you the behavior you want?

@CyrusNajmabadi
Copy link
Contributor Author

CyrusNajmabadi commented Jan 22, 2018

In our case no. Just trying this out produces "message: 'An interface may only extend a class or another interface.'"

image

The same issue exists. Namely a mapped-type that is instantiated with a type parameter is not viewed as an interface (understandable). But it would be nice if this could be relaxed. It appears as if mapped types are the only ones currently that go through this extra 'resolve the real members' step. It would be very nifty if interfaces could have this capability as well.

@CyrusNajmabadi
Copy link
Contributor Author

Note: if some system for this does exist today, we'd def be happy to use it. but my TS-fu is a little too weak with all these new features to have figured out how to make this work.

@tonivj5
Copy link

tonivj5 commented Apr 28, 2019

any news?

I want to do something like this

type IContext<T> = { [K in keyof T]: T[K] }

// Error: A class can only implement an object type or intersection of object types with statically known members.
class Context<T> implements IContext<T> { }

const ctx = new Context<{ id: number, name: string }>();

ctx.id
ctx.name

@dragomirtitian
Copy link
Contributor

dragomirtitian commented Apr 29, 2019

@xxxTonixxx still not possible directly , might I suggest a workaround, use a separate constructor declaration that returns the apropriate intersection:

class _Context<T>  { /* members */ }

const Context: new<T>() => _Context<T> & T = _Context as any;
type Context<T> =  _Context<T> & T

const ctx = new Context<{ id: number, name: string }>();

ctx.id
ctx.name

@tonivj5
Copy link

tonivj5 commented Apr 30, 2019

It works! Thank you very much, good trick 😸

@Llorx
Copy link

Llorx commented Dec 21, 2020

Any update on this?

@lougreenwood
Copy link

Just stumbled across this exact use case...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

7 participants