I know Typescript is structured typing due to the dynamic nature of Javascript, thus features like generic is not the same as other languages nominal type system. Given that, how do we enforce type safety with generic, specifically array? Suppose I have these classes/types:
class X {
fn(env: (number | string)[]) {
if (typeof env[0] === 'string') {
console.log('print string and number')
}
console.log(env[0] === 0)
}
}
class Y extends X {
override fn(env: string[]) {
console.log(env[0] === '0')
}
}
I used classes, but this holds the same with types.
These expression is understandable, since we explicitly stated the type (similar to as if we ignore the fact that both has the same type-less structure):
const x: X = new Y()
const y: Y = new X()
But for example, these expressions are also valid
const arrX: X[] = [y] // works, as intended Y extends X
const arrY: Y[] = [x] // works, but shouldn't, or at least emit a warning
I know that generic like Array in this case get enforced through usage rather than declaration, for example, arrY.forEach(val => val.fn([0]) will break. I know fully well the limitation of a structured type system, so I'm not asking why, or why shouldn't, I'm asking for a good way to enforce such restriction. I don't mind any workaround. What I want to achieve is essentially some way to convey that: We can use an Y as a X, but never X as an Y. I'm also aware that there are more ways to model the association between 2 "types", so I don't need a general solution that can cover all edge cases.
I tried rebrand the generic, looking like this:
type YEnv = string & {__unused: 'Y' }
class Y /* extends break */ extends X {
fn (env: YEnv) {...}
}
Now since YEnv and number|string is incompatible, inheritance is broken. Consumers of such API would need to explicitly cast Y to X to be used in an Array<X>. On any nomimal type system we wouldn't need to do this. It's fine with explicitly casting them, but may not be very intuitive.
In general, TypeScript's structural type system means that type
Xis assignable to typeYdepending on the shapes of the types and not necessarily on their declarations. If one writesclass Y extends X {}without adding conflicting things intoY, then you'll often end up with a situation whereXis assignable toYdespite being a superclass of it.The issue of assignability is only made more complex and difficult to think about given that TypeScript isn't perfectly sound, so some of the allowed assignments "should" be rejected, except that the equivalent assignment must be accepted to allow for existing type hierarchies. See microsoft/TypeScript#9825 for more information. Methods in TypeScript are bivariant in their parameter types unsafely, meaning your
Yreally should not be allowed as a subclass ofX. But it is, and I'm not going to worry about why you've got it this way, other than to suggest to future readers to beware of such things.Anyway, the standard approach when you've got
Xassignable toYwhen you don't want it to be and where you have control over both of the types, is to add something toYto make it incompatible. And the easiest way to do that is to add a member not present inX. A property will do:Here a
Yis know thought by TypeScript to contain a numericyproperty which is not present inX. Since I used thedeclaremodifier this doesn't actually change the emitted JavaScript at all. It just says that, according to TypeScript, it's there. Of course it isn't there at runtime, so any code you write should probably stay away from it, and not try to read it or write it. You could make itprivateto discourage external access, or you could make itundefinedto better model what's happening, or you could really add it toYso that no runtime issues show up. There are all kinds of ways someone might want to proceed, depending on their use cases.The important part is: when you have accidentally compatible types, modify at least one of them to be incompatible.
Playground link to code