Search code examples
typescript

How to prevent TypeScript from allowing to re-assign the discriminator?


While I was playing around with TypeScript I found that this code does compile, but of course throws an error.

type Foo = {
    kind: "foo",
    foo: { foo: string };
}

type Bar = {
    kind: "bar",
    bar: { bar: string};
}

type Union = Foo | Bar;

function doSomething(fooOrBar: Union) {
    fooOrBar.kind = "foo";
    if(fooOrBar.kind === "foo")
        console.log(fooOrBar.foo.foo);
}

doSomething({
    kind: "bar",
    bar: { bar: "bar" }
});

I guess that this is possible, because fooOrBar contains the shared properties between Foo and Bar and therefore looks like {kind: "foo" | "bar"}, which indeed allows the assignment?

In my case I could circumvent this by marking kind as readonly, but that feels more like a workaround than what I actually want. Is there some way to define union as being explicitly only one of the two types, therefore forbidding an assignment to kind?

Playground


Solution

  • TypeScript's type system is intentionally unsound, allowing unsafe code to exist, in order to support some idiomatic JavaScript which is technically unsafe in theory but so useful in practice that they feel the tradeoff is worth it. See microsoft/TypeScript#9825 and Why are TypeScript arrays covariant? for more information about the nature of this unsoundness. With TypeScript you don't get guaranteed safety. It's more like you make safety more likely than you would without it. In most cases the best you can do is write code that discourages unsafe use.

    It is indeed unsafe to allow you to write to the discriminant property of a discriminated union as you show. But unsound property writes are a fact of life in TypeScript, allowing objects to be considered covariant in their property types instead of invariant (see Difference between Variance, Covariance, Contravariance, Bivariance and Invariance in TypeScript). Invariant property types would be safe but make TypeScript very painful to use.


    I looked through the TypeScript GitHub issue list and couldn't find anything specifically reporting this issue, where writing to a discriminant property can lead to a value whose type doesn't match any union member. If you feel strongly about this, you could open an issue yourself about it. Although the fact that there doesn't seem to be an existing one means that, in practice, people don't write the sort of code you're worried about. So there's a good chance it would be closed with a comment to the effect of "yes, this is a design limitation in TS, and it's not worth fixing because the cure would be worse than the disease". Or maybe it wouldn't be. Anyway if someone does open such an issue, that would give an authoritative answer to this question.

    In the absence of an authoritative answer, the closest we can get is to refer to other known unsoundnesses and the best practices surrounding them. If you're worried about writing to a property, you could make it readonly:

    function doSomething(fooOrBar: Readonly<Union>) {
        fooOrBar.kind = "foo"; // error!
        if (fooOrBar.kind === "foo")
            console.log(fooOrBar.foo.foo);
    }
    

    Again, that readonly is just a way to discourage property writes. It doesn't guarantee anything. See Why typescript allows referencing/assigning a readonly type/property by mutable type?. Unsoundness pervades the language.

    Playground link to code