It makes sense that typeof B
does not extend typeof A
, at least in the static type system. It is quite possible that the two types behave identically at runtime, especially since you aren't actually setting the type
property anywhere... but such an "okay at runtime despite looking bad in the type system" behavior is hard for the compiler to reason about. You might need to use a type assertion if you know more than it does.
Let's walk through why the error makes sense:
export type Omit<T, K> = Pick<T, Exclude<keyof T, K>>
type AddDoSomething<T> = Omit<T, "doSomething"> & {
doSomething: () => void
}
class A<T> {
public type!: T // forget about setting this
}
class B<T> extends A<AddDoSomething<T>> { }
So you have explicitly made B<T>
equivalent to A<AddDoSomething<T>>
. But this means that B<T>
is not equivalent to A<T>
, precisely because some values of T
might have a doSomething
property that isn't a no-arg function. And if the constructor B
makes B
instances, and the constructor A
makes A
instances, then you can't say that the B
constructor extends the A
constructor. Your Test
check failure is the same as this failure:
// if typeof B extends typeof A, then I can do this:
const NotReallyA: typeof A = B; // error!
// that error is the same as your Test. Why did it happen?
If that were to compile just fine, then the compiler would think that NotReallyA
was an A
constructor, when it's really a B
constructor. But:
const legitimateA = new A<{ x: number, doSomething: number }>();
legitimateA.type.x = 1; // okay, x is number
legitimateA.type.doSomething = 2; // okay, doSomething is number
const legitimateB = new B<{ x: number, doSomething: number }>();
legitimateB.type.x = 1; // okay, x is number
legitimateB.type.doSomething = 2; // error! doSomething is ()=>void
legitimateB.type.doSomething = () => console.log("ok"); // okay now
You can see that legitimateA and legitimateB act differently... so you'd get different and incompatible behavior if you use B
in place of A
(via NotReallyA
):
const illegitimateA = new NotReallyA<{ x: number, doSomething: number }>();
illegitimateA.type.x = 1; // okay, x is number
illegitimateA.type.doSomething = 2; // okay at compiler time, but not good
Now, since you're not setting type
, in fact the two constructors are essentially the same at runtime. If you really want the compiler to treat B
like an A
constructor, you can do it:
// Are you sure this is okay? Then:
const OkayIGuessA = B as typeof A; // assertion
But it's not possible just to tell the compiler not to worry about the types typeof B
and typeof A
being incompatible.
If you are willing to prevent T
from having its own doSomething
property, or at least not one with a different type, then you can give up on Omit
and use your other method of intersection. This produces a valid subtype of T
and everything is fine... a T & {doSomething(): void}
can be used everywhere a T
can. If T
happens to have a doSomething
property, the intersection makes a useless never
type, so we should probably prohibit that:
// Give up on Omit
class DifferentB<T extends {
doSomething?: () => void;
[k: string]: unknown;
}> extends A<T & { doSomething: () => void }> { }
And we can use it:
const diffB = new DifferentB<{ x: number }>();
diffB.type.x = 1; // okay
diffB.type.doSomething = () => console.log("okay"); // okay
And we don't have to worry about naughty incompatible doSomething
properties:
const forbiddenB = new DifferentB<{ x: number, doSomething: number }>(); // error!
// number is not ()=>void
And we can reassign the constructor because typeof DifferentB
does extend typeof A
:
const DemonstrablyOkayA: typeof A = DifferentB; // also okay
Okay, hope that helps you. Good luck!
Link to code