Typescript is known for its structured typing, which is a result of the dynamic nature of Javascript. This means that features like generics are not the same as in other languages with nominal type systems. So, how can we enforce type safety with generics, especially when dealing with arrays? Let's say we 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 here, but the same applies to types.
These expressions make sense because we explicitly state the type:
const x: X = new Y()
const y: Y = new X()
However, these expressions are also valid:
const arrX: X[] = [y] // works as intended since Y extends X
const arrY: Y[] = [x] // works, but shouldn't, or at least emit a warning
We know that generics like Array in this case are enforced through usage rather than declaration. For example,
arrY.forEach(val => val.fn([0])
will break. I understand the limitations of a structured type system, so I'm not questioning why or why not. I'm looking for a good way to enforce such restrictions. Any workaround is welcome. Essentially, I want to convey that we can use an Y
as an X
, but never an X
as a Y
. I am aware that there are different ways to model the association between two "types", so I don't need a general solution that covers all edge cases.
I attempted to rebrand the generic, like this:
type YEnv = string & {__unused: 'Y' }
class Y /* extends break */ extends X {
fn (env: YEnv) {...}
}
Now, since YEnv
and number|string
are incompatible, inheritance is broken. Consumers of this API would need to explicitly cast Y
to X
to be used in an Array<X>
. In a nominal type system, this wouldn't be necessary. It's okay to explicitly cast them, but it may not be very intuitive.