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

Getters and methods implicit return types should be recomputed upon inheritance. #57365

Closed
6 tasks done
denis-migdal opened this issue Feb 10, 2024 · 12 comments
Closed
6 tasks done
Labels
Suggestion An idea for TypeScript Too Complex An issue which adding support for may be too complex for the value it adds

Comments

@denis-migdal
Copy link

denis-migdal commented Feb 10, 2024

πŸ” Search Terms

inheritance getter and attribute inconsistency

βœ… Viability Checklist

Sorry it's me again, but I'm still having issue with TS lack of precision on return types.

⭐ Suggestion

Getter/method implicit return type should be recomputed upon inheritance.
Alternatively, a way to indicate we want the return type to be recomputed should be provided e.g. something like :

  • class Base { get foo(): infer {...} }) or class Child extends Base { foo: infer; }.

I think the current behavior may lead to undetected bugs.
The suggestion I make may not be the best one, but I think the issue does exist.

class Base {
    get foo(): number|string {} // we explicitly state the return type
    // i.e. we expect all inheriting classes to return the same type except if explicitly stated otherwise.
    get faa() {} // we do not indicate the return type, i.e. we say "please TypeScript deduce it for me".
    // i.e. it may be slightly different in inherited classes, and TypeScript should do its best to give us the best return type.
    // however, currently, the return type is computed only once, inside the Base class.
}

This behavior is inconsistent with the behavior of attributes upon inheritance, which is confusing:

class Base {
    protected _foo: string|number = 4;
}
class Child extends Base {
    protected override _foo = 4; // typeof this._foo = number.
}

πŸ“ƒ Motivating Example

abstract class Base{

	protected abstract _foo: number|string; // idem for {val: number};
	protected abstract _faa: number|string; // idem for {val: number};

	get foo(): string|number {
		return this._foo;
	}

	get faa() {
		return this._faa;
	}
}

class Child extends Base {
	protected override _foo: Base["_foo"] = 4; // string|number
	protected override _faa = 4; // number // idem for {val: number, another: ...}

	/* meh, too verbose
	override get faa() {
		return super.faa as typeof this._faa;
	}*/
}

let c = new Child();

let foo = c.foo; // string|number
let faa = c.faa; // string|number, when we only expect number;

class GrandChild extends Child {
	override _foo = "str"; // ok (expected)
	//override _faa = "str";    // error (expected)

	override get faa() {
		return "str"; // should raise an error as it breaks Child expected API, could lead to bugs.
	}
}

Playground Link

πŸ’» Use Cases

  1. What shortcomings exist with current approaches?

Too verbose, potentially risky, and a pain to do so on each of the inherited classes:

get faa() {
		return super.faa as typeof this._faa;
		// if super.faa is rewritten to return a string even if this._faa is a number, this could lead to undetected bugs.
}

For complex types, could be tricky to write, and could break encapsulation (i.e. may require to know how the returned value is computed to deduce what its type should be).

Or even worst:

let c = new Child();
let faa = c.faa as number; // DANGEROUS as a grandchild could override get faa() without raising a TS error.
@jcalz
Copy link
Contributor

jcalz commented Feb 10, 2024

#38008 is for methods, not sure if there’s a duplicate for getters

@denis-migdal
Copy link
Author

#38008 is for methods, not sure if there’s a duplicate for getters

This could help in some cases, although you still need to know the returned type (which can be tricky), and to explicitly add it in the children classes. It'll be better if an implicit return type could make it auto-magically. Or to have a keywork like "infer" to tell TS to re-infer the return type upon inheritance.

In other cases, it would be impossible to use as some types are currently impossible to describe in TS (e.g. when it requires generic instantiation of a type or when TS widen types).

I use another workaround :

abstract class Base {
    protected _foo: string|number = 42;
    abstract readonly foo: typeof Base["_foo"];
}

class Child extends Base {
     protected _foo = 42; // numbers
     override foo = this._foo; // requires to "re-implement" the public RO prop in each sub-classes.
}

@jcalz
Copy link
Contributor

jcalz commented Feb 10, 2024

You've got protected in there which messes things up, but otherwise you could use polymorphic this to do what you're describing:

abstract class Base {
	abstract _foo: number | string;
	get foo(): this["_foo"] {
		return this._foo;
	}
}

class Child extends Base {
	override _foo = 4;
}

let c = new Child();
let foo = c.foo; // number

The problem with "auto-magic" or "infer" is that it sounds like you want the compiler to do a lot of extra work that might not scale. Essentially you're either asking for all classes to be implicitly generic (which is essentially what polymorphic this is, and almost works here except you can't access private/protected methods this way in the type system) or you're asking for every subclass to cause TS to re-analyze the base class types again (which doesn't scale) or you should settle for declare-ing more narrowed types in subclasses (which would work fine except is not supported for methods or getters).

But these are just my opinions, we'll see what the TS team says.

@denis-migdal
Copy link
Author

Thanks for your answer.

You've got protected in there which messes things up, but otherwise you could use polymorphic this to do what you're describing

Yep, that is one of the issue. The other is that from this there are types you can't describe in TS.

Maybe one workaround is to make the member public but name it with a symbol ?
This would be a little dirty though.

The problem with "auto-magic" or "infer" is that it sounds like you want the compiler to do a lot of extra work that might not scale.

I'm starting to think that all issues I had recently comes from TS type widening inside function body...
If, for example, the : infer would prevent type widening, I assume the compiler wouldn't have to do much more work once the return type is deduced once, e.g.

foo(): infer {
     return this._foo; // return type is typeof this._foo
     // type FooReturnType<T extends typeof this._foo> = T
}

The return type could be partially pre-computed at the class level, to not do too much extra-computations on each calls ?
And as it is something you explicitly opt-in, this could be more acceptable ?

@denis-migdal
Copy link
Author

or you should settle for declare-ing more narrowed types in subclasses (which would work fine except is not supported for methods or getters).

declare doesn't seem to work with override.

Maybe something like that could be acceptable ?

class Child {
   override foo: infer; // override: ensure "foo" exists in the base class.
   // infer, an opt-in to recompute the type (for an attribute) or the return type (for a getter or method).
}

Also, you talked about scalability, I understand for the auto-magically option, but I do not understand why an opt-in like:

class Base {
     get foo(): infer {} // e.g. if no "foo" in children classes, adds a `override foo: infer;` to it ?
}

Would cost more than a :

class Child extends Base {
       override get foo() {
             return super.foo as typeof this._foo // <- you also need to recompute the return type.
       }
}

@MartinJohns
Copy link
Contributor

Sounds like #10570.

@denis-migdal
Copy link
Author

Sounds like #10570.

I don't think so.

#10570 is about the type of attribute that are recomputed from the initializer expression, leading to troublesome behavior when initializing them to [], {} or null.
My issue is about method and getter implicit return type that aren't recomputed in the children classes while we could have more precise types.

@RyanCavanaugh
Copy link
Member

This violates the principle that you can (almost always) move a piece of code across the .ts -> .d.ts boundary without change in behavior. Since we can't see the base class's source code in a .d.ts, there'd be no way to recompute the return type.

@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript Too Complex An issue which adding support for may be too complex for the value it adds labels Feb 12, 2024
@denis-migdal
Copy link
Author

This violates the principle that you can (almost always) move a piece of code across the .ts -> .d.ts boundary without change in behavior. Since we can't see the base class's source code in a .d.ts, there'd be no way to recompute the return type.

?

But you still have access to the base class methods' return type, no ?
You could simply have 2 returns types :

  • the simple one, pre-computed for the class.
  • the complex one, e.g. FooReturnType<T extends typeof this._foo, etc>, used internally to recompute the return type in the child class.

@RyanCavanaugh
Copy link
Member

You could simply have 2 returns types

There's nothing simple about this at all, that's the problem

@denis-migdal
Copy link
Author

There's nothing simple about this at all, that's the problem

Then, if it is not possible, could it be possible to at least be able to override a method without having to give the implementation (i.e. we change the method signature, but not its implementation) ?

override foo(...): Child;
// or
override foo(...): Child = inherit; // we explicitly state we do not provide an implementation.

It will help a little for some cases.

And to be able to use this maybe :

  • enable to use protected member in this["member_name"].
  • enable to provides generic parameters when fetching a member, e.g. this[<T>"..."] or AsGeneric<this["..."], T> or whatever, cf previous issues.

?

@typescript-bot
Copy link
Collaborator

This issue has been marked as "Too Complex" and has seen no recent activity. It has been automatically closed for house-keeping purposes.

@typescript-bot typescript-bot closed this as not planned Won't fix, can't repro, duplicate, stale Feb 16, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Suggestion An idea for TypeScript Too Complex An issue which adding support for may be too complex for the value it adds
Projects
None yet
Development

No branches or pull requests

5 participants