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™)
CodePudding user response:
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 Cat
s 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 class
es 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;
}