0

Given

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
}

class B<TT> extends A<AddDoSomething<TT>> { }

// Just to check type match
type Test<T extends typeof A> = unknown

type C = Test<typeof B> // Error -> Type 'typeof B' does not satisfy the constraint 'typeof A'. Types of property 'type' are incompatible.

Why is that? I was trying to add a property to some type, and by omitting first, i want to make sure that there is no overlap type to 'doSomething'

EDIT -------

BUT If i change the type of

AddDoSomething

to

type AddDoSomething<T> = T & {
  doSomething: () => void
}

Then everything is alright, any insight? Is there any way i could overwrite the method of 'doSomething' without omitting it?

Link to Typescript Playground

jcalz
  • 264,269
  • 27
  • 359
  • 360
Andree Christian
  • 437
  • 4
  • 16
  • 1
    theres no implementation of `doSomething` in `B` – Daniel A. White Jun 06 '19 at 17:03
  • @DanielA.White what do you mean by that? Also see my editted question – Andree Christian Jun 06 '19 at 17:07
  • Is there a reason you're not setting the value of `type` in the constructor of `A`? Under `--strict` compiler options this is an issue... if I do `const a = new A<{foo: string}>()`, and I access `a.type.foo.charAt(0)` it will blow up at runtime. It doesn't *really* matter for the answer to this question, but it makes the argument for why one breaks and the other succeeds harder to frame. – jcalz Jun 06 '19 at 17:36
  • @jcalz Yes, long story short i was hacking my way to get the generic T type provided for base class A, in my code it was written with __DO_NOT_USE_TYPE_REFERENCE_ONLY_type so that later i could get the generic of A (the T) by calling A['__DO_NOT_USE_TYPE_REFERENCE_ONLY_type'] --> mainly for Intellisense purpose. – Andree Christian Jun 06 '19 at 17:43

1 Answers1

2

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

jcalz
  • 264,269
  • 27
  • 359
  • 360