Nandin Borjigin
Nandin Borjigin

Reputation: 2154

Why string and symbol are working inconsistently regarding discriminated unions?

Why the second implementation is emitting an error in the following code?

type Op<T> = (value: T) => void
// Usecase: we need to mix some Op<string> with some Op<ohter> in one array


// implementation 1
type MixedOp<T> = 
    | (Op<string> & { __isString: true }) // __isString is used for runtime check
    | (T extends string ? never : (Op<T> & { __isString: false }))

function run<T>(funcs: MixedOp<T>[], value: string, transform: (value: string) => T) {
    for (let fun of funcs) {
        if (fun.__isString) {
            fun(value)
        } else {
            fun(transform(value))
        }
    }
}


// implementation 2
// the only difference from implementation 1 is replaced __isString with a symbol
// while that replacement leads to an error
const IsStringSymbol: unique symbol = Symbol()

type MixedOp2<T> = 
    | (Op<string> & { [IsStringSymbol]: true })
    | (T extends string ? never : (Op<T> & { [IsStringSymbol]: false }))


function run2<T>(funcs: MixedOp2<T>[], value: string, transform: (value: string) => T) {
    for (let fun of funcs) {
        if (fun[IsStringSymbol]) {
            fun(value) // Argument of type 'string' is not assignable to parameter of type 'string & T'.
        } else {
            fun(transform(value)) // Argument of type 'T' is not assignable to parameter of type 'string & T'.
        }
    }
}

Playground link

Upvotes: 0

Views: 49

Answers (1)

jcalz
jcalz

Reputation: 329418

This looks like a current design limitation or missing feature in TypeScript. According to microsoft/TypeScript#36463 and microsoft/TypeScript#36230, you currently cannot use a symbol-named property as the discriminant of a discriminated union.


If I have a regular discriminated union where the discriminant key is a string literal,

const k = "a"
type U = { [k]: true, b: number } | { [k]: false, c: string };
declare const u: U;

then I can use it to discriminate a value of that union type, as long as I access the property directly via a literal key:

// direct access
if (u["a"]) { // you could also write u.a
    u.b.toFixed(); // okay
} else {
    u.c.toUpperCase(); // okay
}

If I try to access the discriminant where the key is a const stored in a variable, it breaks:

// via variable
if (u[k]) {
    u[k].toFixed(); // error
} else {
    u[k].toUpperCase(); // error
}

The compiler doesn't attempt to do this because apparently the check ("is this k a const of a discriminant type?") degrades compiler performance. Code indexes into objects all the time, so anything extra the compiler has to do for each such access will slow things down. Does it degrade performance too much to be worthwhile? Not sure. That's what microsoft/TypeScript#36230 is about.


If you try to do the same thing where the discriminated key is a symbol, you run into an issue because there is no way to use the key directly:

const k = Symbol();
type U = { [k]: true, b: number } | { [k]: false, c: string };
declare const u: U;

/*
if (u[???]) { // what do I put in the ???
    u.b.toFixed();
} else {
    u.c.toUpperCase();
}
*/

if (u[k]) {
    u[k].toFixed(); // error
} else {
    u[k].toUpperCase(); // error
}

This has the effect that you cannot use a symbol-named property to discriminate a union. Which is what microsoft/TypeScript#36463 is about.


So, what can you do? Presumably you should still just use the __isString string key and move on. Additionally, you could go to microsoft/TypeScript#36463, give it a 👍, and explain why you need this feature (since the issue is marked as "awaiting feedback", the want to hear why people want this and how useful it would really be to implement).

Playground link to code

Upvotes: 1

Related Questions