1

I have a class like the following:

const p = Symbol();
class Cat {
  // private data store
  private [p]:{[key:string]: unknown} = {};

  constructor() {}

  jump(height:number):void {
    // ...
  }
}

Elsewhere I have a class that has a reference to an instance of a Cat and exposes it as a property. However, for reasons, this property is a function:

class Microchip {
  constructor(cat:Cat) {
    this._cat = () => cat;
  }

  get cat():() => Cat {
    return this._cat;
  }
}

However, it's actually a little more complicated than that. The cat property isn't just a function, it's a Proxy to a function that exposes members of the underlying Cat instance:

interface CatQuickAccess {
  jump(height:number):void;
  ():Cat;
}

class Microchip {
  constructor(cat:Cat) {
    this._cat = new Proxy(() => cat, {
      get(_, propName) {
        return cat[propName];
      }
    }) as CatQuickAccess;
  }

  get cat():CatQuickAccess {
    return this._cat;
  }
}

The CatQuickAccess interface is burdensome as the Cat class has many, many complex members, all of which must be carefully copied and maintained to ensure consistency with the underlying class.

Generic functions allow the following syntax which is very close to what I'd like to accomplish:

function getCatProp<T extends keyof Cat>(key:T):Cat[T] {
  return this._cat[key];
}

I was hoping I could use a similar technique to avoid listing out all of the Cat properties and instead have TypeScript infer the properties directly from the Cat class, but either this is not possible or I haven't yet sussed out the right syntax.

The following generates two very reasonable errors:

  • An index signature parameter type cannot be a literal type or generic type. Consider using a mapped object type instead.
  • 'key' refers to a value, but is being used as a type here. Did you mean 'typeof key'?
// WRONG
interface CatQuickAccess {
  [key:keyof Cat]:Cat[key];
  ():Cat;
}

I thought about using a merge type as below:

// WRONG
type CatQuickAccess = Cat & {
  ():Cat;
}

However, this is misleading, as it makes it look like you can pass around the quick access value as an instance of a Cat, when actually it's just a function with some properties on it. It has a very similar shape, but would fail simple type checks like instanceof Cat at runtime.

I've been experimenting with the spread operator to see if I could get TypeScript to understand what I'm trying to describe, but so far this has not worked at all:

// VERY WRONG
type CatQuickAccess = {
  ...Cat,
  ():Cat;
}

Is there a solution here, or does maintaining this very odd construct require the signature duplication?

(I believe I have provided a sufficient MCVE example, but please keep in mind that the actual code is more complex in its implementation and history. If it's possible, I'd like my question answered as I've stated it. Adjusting the structure of the code is not possible for reasons™)

JDB
  • 25,172
  • 5
  • 72
  • 123
  • Do you think you could edit the code here so that the only errors are the ones you're asking about? Like, all the errors [here](https://tsplay.dev/w1102w) should be resolved. – jcalz Sep 28 '21 at 18:59
  • Note that TypeScript (mostly) only cares about *structural* compatibility. So while `x instanceof Cat` implies that `x` has a type that's assignable to the type named `Cat`, the reverse is not true. The type named `Cat` is just an interface that the instances of the `Cat` class conform to. You can make your own value of type `Cat` without using the class constructor at all. All this is saying that: `Cat & {(): Cat}` is not wrong; the type `{ jump(height:number):void;}` is completely equivalent to `Cat` as far as TS is concerned. – jcalz Sep 28 '21 at 19:02
  • So, that being said, I'm not sure what answer you want here. Maybe you really want to give `Cat` a `private` member so that the `Cat` interface [acts like a nominal type](https://github.com/microsoft/TypeScript/wiki/FAQ#when-and-why-are-classes-nominal) and not a structural type. Like [this](https://tsplay.dev/WK8gow) (where you define the "public-facing" part of `Cat` as `Pick`). Let me know if you want any of this as an answer. – jcalz Sep 28 '21 at 19:10
  • @jcalz - I believe that `Pick & {():Cat}` is exactly what I was looking for. I have several functions that take `Cat` as an argument and I want to make sure an error is thrown if I pass in a `CatQuickAccess` (as there is a good chance the function will fail at runtime). I didn't think of using `Pick`. – JDB Sep 28 '21 at 19:35
  • `Pick` probably does not change anything unless `Cat` has a private member; see [this](https://tsplay.dev/we091W). Do you understand that issue? Are you okay with an answer suggesting the `Pick` solution *along with* a private member of `Cat` to get nominal-like typing here? – jcalz Sep 28 '21 at 19:40
  • @jcalz - Yes. In the real codebase, the class does have private members, so this is not an issue. Your solution solved the issue I was having. – JDB Sep 28 '21 at 19:41

1 Answers1

1

TypeScript's type system is mostly structural and not nominal; generally speaking, if type A and type B have the same shape, then they are the same type, even if you use different names to describe them or if they were declared separately. And this is also true of class types; the class Cat {/*...*/} declaration brings a type named Cat into existence, and this Cat type is an interface that Cat instances will conform to.

And something can conform to the same interface whether or not it was constructed via the Cat constructor:

class Cat {
    constructor() { }
    jump(height: number): void {
        console.log("Jumping " + height)
    }
}

const x: Cat = new Cat(); // okay
const y: Cat = { jump() { } } // also okay

(Note: I'm using your original Cat definition here with no private members.)

So this issue you are worried about, where someone might use erroneously use a CatQuickAccess as if it were a Cat, can happen no matter how you define CatQuickAccess. A value v of type Cat may or may not have v instanceof Cat be true.

For the version of Cat that you have in your example code, all of the following definitions of CatQuickAccess are equivalent (that is, they are mutually assignable):

interface CQA1 {
    (): Cat;
    jump(height: number): void;
}

type CQA2 = Cat & { (): Cat };

interface CQA3 extends Cat {
    (): Cat;
}

// assignability tests:
declare var c1: CQA1;
declare var c2: CQA2;
declare var c3: CQA3;
c1 = c2; // okay
c1 = c3; // okay
c2 = c1; // okay
c2 = c3; // okay
c3 = c1; // okay
c3 = c2; // okay

So, pick one and just document what you're doing if you care about preventing non-Cat instances from showing up where Cats are expected.


Really, the issue here is that you want Cat to be a nominal type, where types declared elsewhere cannot be considered equivalent to it. The easiest way to do this for classes is to give the class a private or protected property; this makes classes behave nominally:

class Cat {
    constructor() { }

    private catBrand = true; // this simulates nominal typing 

    jump(height: number): void {
        console.log("Jumping " + height)
    }
}


const x: Cat = new Cat(); // okay

const y: Cat = { jump() { }, catBrand: true } // error!
// -> ~  Property 'catBrand' is private in type 'Cat'

(Note that, as you said, you already have private members in your actual class, so there's no reason to add an extra one; just use the ones you have.)

Once you've got a nominal-like Cat, then the only thing you need to do is define the "public part" of the Cat interface. One thing you can do is take advantage of the fact that the keyof type operator does not see private key names, so you can use the Pick<T, K> utility type to get the public part of Cat (see this answer for more info):

type PublicPartOfCat = Pick<Cat, keyof Cat>;
/* type PublicPartOfCat = {
    jump: (height: number) => void;
} */.

And so now the best way to write QuickCatAccess is to extend the public part of Cat with a call signature:

interface CatQuickAccess extends Pick<Cat, keyof Cat> {
    (): Cat;
}

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360