Typescript enforce generic type safety

40 Views Asked by At

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.

1

There are 1 best solutions below

0
jcalz On BEST ANSWER

In general, TypeScript's structural type system means that type X is assignable to type Y depending on the shapes of the types and not necessarily on their declarations. If one writes class Y extends X {} without adding conflicting things into Y, then you'll often end up with a situation where X is assignable to Y despite 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 Y really should not be allowed as a subclass of X. 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 X assignable to Y when you don't want it to be and where you have control over both of the types, is to add something to Y to make it incompatible. And the easiest way to do that is to add a member not present in X. A property will do:

class Y extends X {
    declare y: number; // <-- add this
    override fn(env: string[]) { }
}

Here a Y is know thought by TypeScript to contain a numeric y property which is not present in X. Since I used the declare modifier 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 it private to discourage external access, or you could make it undefined to better model what's happening, or you could really add it to Y so 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