superclass base class generic type infer contextual infer parent generic super constructor
Make it possible to infer superclass type parameters / constructor overload from the super call
I'm working on an intermediate library with typings for a legacy codebase. That intermediate library is used in third party to use the functionality from the main codebase (called adapters). There are several supported ways to access this functionality:
const foo1 = Adapter(options);const foo2 = new Adapter(options);class Foo3 extends Adapter { ... } - The constructor must call the superclass constructor with the options.Now the problem is that the Adapter "class" (actually an ES5-style "function" class) has some properties that only exist if specific options are passed to its constructor. I'm able to model this behavior for case 1 and 2, but not 3 - at least not without manually specifying the type.
Here's what I have so far. The code is split into three parts: main codebase, intermediate library, 3rd party / user code. (Playground link)
// legacy code, cannot change:
declare class InternalAdapter {
// actually an ES5-style class function, can be called with and without new!
someProp1: any;
someProp2: any;
// I want to override this type
cacheObj: Record<string, any> | undefined;
}
// ========================
// My code, can change:
interface AdapterOptions {
name: string;
cache?: boolean;
}
type AdapterInstance<T extends AdapterOptions> = T extends {
cache: true;
}
? Omit<InternalAdapter, "cacheObj"> & {
cacheObj: Exclude<InternalAdapter["cacheObj"], undefined>;
}
: Omit<InternalAdapter, "cacheObj">;
interface AdapterConstructor {
new (adapterName: string): AdapterInstance<{name: string}>;
new <T extends AdapterOptions>(adapterOptions: T): AdapterInstance<T>;
(adapterName: string): AdapterInstance<{name: string}>;
<T extends AdapterOptions>(adapterOptions: T): AdapterInstance<T>;
}
declare const Adapter: AdapterConstructor;
// ========================
// User code, should be as simple as possible
const name = "foobar";
const options = { name };
const test1 = Adapter(name);
test1.cacheObj; // does not exist, expected
const test2 = new Adapter(name);
test2.cacheObj; // does not exist, expected
const test3 = Adapter(options);
test3.cacheObj; // does not exist, expected
const test4 = new Adapter(options);
test4.cacheObj; // does not exist, expected
const test5 = new Adapter({ ...options, cache: true });
test5.cacheObj; // exists, expected
// Here, the problems start:
class Test6 extends Adapter<AdapterOptions> {
constructor(options: AdapterOptions) {
super(options);
this.cacheObj; // does not exist, expected
}
}
class Test7 extends Adapter<AdapterOptions> {
constructor(options: AdapterOptions) {
super({ ...options, cache: true });
this.cacheObj; // does not exist, unexpected
}
}
class Test8 extends Adapter<AdapterOptions & {cache: true}> {
constructor(options: AdapterOptions) {
super({ ...options, cache: true });
this.cacheObj; // exists, but I have to duplicate the type
}
}
Notice how the class definitions are all awkward. In Test6 I have to duplicate the generic type AdapterOptions or the super call will default to the string constructor.
In Test7 this is actually wrong, because the generic type overrides the conditional behavior for the cacheObj. This can be fixed like in Test8, but that is really ugly. IMO, TypeScript should be able to infer the type arguments from a constructor call if that type argument matches the class' type argument
Show how this would be used and what the behavior would be - Ideally it should be like this:
class Test9 extends Adapter {
constructor(options: AdapterOptions) {
super({ ...options, cache: true });
this.cacheObj; // should exist
}
}
The super call would be used to infer that the 2nd constructor overload is the correct one:
new <T extends AdapterOptions>(adapterOptions: T): AdapterInstance<T>;
and therefore the instance type would be AdapterInstance<AdapterOptions & {cache: true}>.
My suggestion meets these guidelines:
I think a simpler example that demonstrates what you want would be like this: you essentially want to extend a class that takes a generic argument then infer that generic argument by how you call the super constructor:
class ExampleBase<T extends number> {
public value : T;
constructor(val: T){
this.value = val;
}
}
// when creating a new instance the generic is determined by the argument.
let x = new ExampleBase(5);
const y: 5 = x.value; // x.value is known to be 5.
// want ExampleBase<5> to be infered by calling super(5) below.
class Example extends ExampleBase {
constructor(){
super(5);
this.value;
}
}
This would basically fall under the "infer type in declaration based on how I use it" which has never made it into typescript in any scenario.
Part of the reason your declarations are so awkward is that the data you actually care about is buried deep in the definitions of your generic arguments, if you rewrite it so the generic argument just specifies whether a cache is present or not it would get cleaned up pretty easily:
interface AdapterInstance<HasCache extends boolean | undefined = boolean> extends InternalAdapter {
// if HasCache is specified as true or false this will narrow the type
// otherwise AdapterInstance<boolean> or just AdapterInstance resolves to the same type as base type.
cacheObj: HasCache extends true ? Exclude<InternalAdapter["cacheObj"], undefined> : undefined;
}
interface AdapterOptions<C extends boolean | undefined = boolean | undefined> {
name: string;
cache: C;
}
interface AdapterConstructor {
new <HasCache extends boolean | undefined = undefined>(adapterOpts: string | AdapterOptions<HasCache>): AdapterInstance<HasCache>;
<HasCache extends boolean | undefined = undefined>(adapterOpts: string | AdapterOptions<HasCache>): AdapterInstance<HasCache>;
}
declare const Adapter: AdapterConstructor;
When the constructor is called with a string the generic argument can't be inferred so it uses the default value of undefined which eliminates the need for 2 different call signatures but still does what you want:
let x = new Adapter(""); // generic defaults to undefined, if you rather default to boolean it is easy to change above.
x.cacheObj // undefined. typeof x <==> AdapterInstance<undefined>
let y = new Adapter({name: "hello", cache: true})
y.cacheObj // Record<string, any>. typeof y <==> AdapterInstance<true>
declare const unknownBool: boolean;
let z = new Adapter({name: "test3", cache: unknownBool})
z.cacheObj; // Record<string, any> | undefined. typeof z <==> AdapterInstance<boolean>
also when subclassing the generic still needs to be specified but instead of AdapterOptions & {cache: true} which is super messy, the generic it takes is just whether it has cache, so you just do Adapter<true> which is much cleaner.
class Test1 extends Adapter<true> {
constructor(v: AdapterOptions){
super({...v, cache: true});
this.cacheObj; // known to be Record<string, any>
}
}
Thanks for the detailed response - I'll try that out.
This would basically fall under the "infer type in declaration based on how I use it" which has never made it into typescript in any scenario.
I fear you are right with that assessment, which is a bit sad. I'm the one person advocating TypeScript in that project and these design decisions (or limitations, whatever you might call them) make my job harder than necessary :(
A counter example to your suggestion would be that this case would break:
class Box<T extends number = number> {
public value: T;
constructor(val: T){
this.value = val;
}
}
let x = new Box(0); // typeof x <==> Box<0>
class Counter extends Box { // use default Box<number>
constructor(){
super(0)
}
increment(){
this.value = this.value + 1;
}
}
Right now extends Box uses the default generic for Box, by your suggestion the generic should be inferred from what is passed to the super constructor which is 0, but if that was inferred then the increment function would fail because now this.value is forced to be 0 just because that is what we initially set it to.
Short answer: in general inferring generics from calls to super would be more harmful than helpful.
I'd also like to explain why you are getting weird behaviour with extends Adapter not letting you use the second constructor: When you use extends Base it first checks the constructor signatures of the base class that support the given number of generics, so if only one constructor has no generics that is the only one allowed to be used in the super call, then the return value of that constructor is used as the prototype for the rest of the class.
If multiple constructors support the given number of generics they must all return the same instance type or typescript disallows that base class.
So in your case the second call signature should have a default value for the generic that gives an instance that is consistent with the other call signature:
interface AdapterConstructor {
new (adapterName: string): AdapterInstance<{name:string}>;
new <T extends AdapterOptions = {name:string}>(adapterOptions: T): AdapterInstance<T>;
(adapterName: string): AdapterInstance<{name: string}>;
<T extends AdapterOptions = {name: string}>(adapterOptions: T): AdapterInstance<T>;
}
this will allow Test6 extends Adapter to let you call the super call signature as you would expect.
I currently prefer the first version of AdapterInstance with the distinct parameters (actually I have two of those in my usecase). Just in case I need the one in your last post - how would AdapterInstance look like?
The last code block for AdapterConstructor is compatible with your current code. Because of differences in whether strict null check is enabled or not as well as not being totally clear on exactly what behaviour you want, I'm not sure how to suggest anything more concrete.
I do think the solution to cleaning this up involves adding some optional generics, primarily to AdapterOptions ideally in a way that made AdapterOptions<true> equivalent to AdapterOptions & {cache: true} which may simplify some of your code, one possible implementation would be like this:
interface _AdapterOptionsBase<C extends boolean | undefined = boolean> {
name: string;
cache?: C;
}
// if no generic is given will default to void and just use _AdapterOptionsBase as is
// if a generic is given (true) then this uses Required<_AdapterOptionsBase<HasCache>>
// to denote that the cache field is definitely present.
type AdapterOptions<HasCache extends boolean | void = void
> = [HasCache] extends [boolean] ? Required<_AdapterOptionsBase<HasCache>> : _AdapterOptionsBase
While this would let Test7 and Test8 just extends Adapter<AdapterOptions<true>> but I'm not sure if this would necessarily improve the overall situation since it still has weird edge cases.
The last few comments for you are:
AdapterOptions to specify alternate meaning but you would need to figure out what values other than true might give that meaning.compatible with your current code
Ok that's what I missed. I assumed this should somehow work with one of your proposals.
Thanks for your help. I finally arrived at this https://github.com/ioBroker/adapter-core/pull/172/files, which still has 1-2 weird edge cases but should mostly work.
This missing feature has been a thorn in my side for some time and seems like an essential language addition. As a counter point to @tadhgmister 's counter point, the code as written should be broken.
The extended type T is more specific than number, therefor usages of T should be expected to be more specific than the default assigned type. In the example new Box(0) would disallow setting value = 1, while the subclass is allowed to do this using the same initial value. I would argue the current behavior presents like a bug.
Aside from correctness, inferring generics from super calls is essential because to the best of my knowledge there is currently no way to obtain the type of that parameter otherwise. Because super is a keyword with special behavior, if the intended behavior is for the ancestor class to receive the exact inferred type there's not an alternate path via property access, inferred optional types or any other variety of black magic typing.
This particular issue makes it considerably more difficult to migrate a project containing hundreds of thousands of lines to Typescript where it would otherwise be a relatively painless transition. Please consider approving the proposal and I would be happy to look into submitting a pull request.
To clarify here's a proposed use case:
class Form<Fields extends Record<string, string>> {
constructor(private fieldTypes: Fields) {}
getFieldValue(field: keyof Fields) {/* ... */}
}
class RegistrationForm extends Form {
constructor() {
super({
firstName: 'text',
lastName: 'text',
email: 'email',
password1: 'password',
password2: 'password',
terms: 'checkbox',
})
}
}
const form = new RegistrationForm()
form.getFieldValue(/* suggestions from inferred type go here */)
+1 to this
TypeScript, applied to certain use cases, e.g. subclassing, feels very verbose and unpleasant to work with at the moment. This is one of a few things needed to combat such verbosity.
I found a workaround for this.
class Type<D = any> {
create(): D;
// and other such nonsense
}
class NumberType extends Type<number> {
create() { return 0; }
}
type GetType<T> = T extends Type<infer D> ? D : unknown; // does not work, as noted in this issue.
type GetType2<T> = T extends Type & { create(): infer D } ? D : unknown; // works!
GetType2 checks for complete type overlap and inspects something which has defined type information, could use any property or function defined on the super class. I hope this helps others! It's a hack, but it works.
Most helpful comment
This missing feature has been a thorn in my side for some time and seems like an essential language addition. As a counter point to @tadhgmister 's counter point, the code as written should be broken.
The extended type T is more specific than number, therefor usages of T should be expected to be more specific than the default assigned type. In the example
new Box(0)would disallow settingvalue = 1, while the subclass is allowed to do this using the same initial value. I would argue the current behavior presents like a bug.Aside from correctness, inferring generics from super calls is essential because to the best of my knowledge there is currently no way to obtain the type of that parameter otherwise. Because super is a keyword with special behavior, if the intended behavior is for the ancestor class to receive the exact inferred type there's not an alternate path via property access, inferred optional types or any other variety of black magic typing.
This particular issue makes it considerably more difficult to migrate a project containing hundreds of thousands of lines to Typescript where it would otherwise be a relatively painless transition. Please consider approving the proposal and I would be happy to look into submitting a pull request.
To clarify here's a proposed use case: