SCB
SCB

Reputation: 6149

Getting type guard like functionality from conditional typed function

I've got the following minimal example that makes use of conditional types.

const wrap = <T extends boolean>(val: number, foo: T): T extends true ? {foo: number} : {bar: number} => {
    if (foo) {
        return {foo: val} as any;
    }
    return {bar: val} as any;
}

const test = Math.random() < 0.5;
const val = wrap(21, test);

if (test) {
    console.log(val.foo);
} else {
    console.log(val.bar);
}

(In playground).

It throws an error at both of the console.log statements:

Property 'foo' does not exist on type '{ foo: number; } | { bar: number; }'.
  Property 'foo' does not exist on type '{ bar: number; }'.(2339)
Property 'bar' does not exist on type '{ foo: number; } | { bar: number; }'.
  Property 'bar' does not exist on type '{ foo: number; }'.(2339)

However if test is true, the output should only ever be {foo: number}, and if it's false, it should only ever be {bar: number}.

I am aware that I can use:

if ('foo' in val) {
  console.log(val.foo);
} else {
  console.log(val.bar);
}

But that seems to defeat the purpose of the conditional types in the function definition.

I'd like to know if there's a way to discriminate the type using the value input in the function instead of the value returned from the function. Any help is much appreciated.

Upvotes: 0

Views: 135

Answers (2)

jcalz
jcalz

Reputation: 329248

I'd say this is a case of TypeScript's lack of support for what I've been calling "correlated union types". See microsoft/TypeScript#30581 for a discussion.

The compiler sees the type of test as true | false (which is displayed as boolean, but is treated as the union of the two boolean literal types), and val as type {bar: number} | {foo: number}:

const test = Math.random() < 0.5; // boolean
const val = wrap(21, test); // {bar: number} | {foo: number}

These types are not wrong, but they are not very helpful for the kind of subsequent testing you are doing. The compiler sees the types of test and val as being completely uncorrelated; since each variable is a union of two members, the pair [test, val] could be considered to be of the four-member union [true, {foo: number}] | [true, {bar: number}] | [false, {foo: number}] | [false, {bar: number}] as far as the compiler knows. Any information about the correlation between test and val has been discarded by the compiler. It just does not keep track of this for you.

Indeed, if the compiler attempted to keep track of possible correlations between the types of every set of possibly-related variables, its performance would be catastrophically poor.

(TS 4.4 update: this next line is no longer strictly true, although the fix at microsoft/TypeScript#44730 still does not work on multiple correlated variables. See microsoft/TypeScript#12184 for a related request to keep track of such correlations with boolean variables, and the reply there is that it would probably be too complex to implement.)

At some point I had suggested an "opt in" version of such analysis (see microsoft/TypeScript#25051) where you could tell it that the code block should be analyzed twice, once for each union member of test. But this request was essentially declined.

For now, there just isn't any support for arbitrarily correlated union types. You will have to work around it.


The easiest workaround to the general case of correlated unions is to use type assertions; the compiler can't tell what type val is, so you just tell it:

if (test) {
  console.log((val as { foo: number }).foo);
} else {
  console.log((val as { bar: number }).bar);
}

The other general solution involves repetition, to force the compiler to walk through the different possibilities via control flow analysis:

if (test) {
  const val = wrap(21, test);
  console.log(val.foo);
} else {
  const val = wrap(21, test);
  console.log(val.bar);
}

Neither of those are satisfying here... it would undoubtedly be better to refactor so that the compiler isn't expected to keep track of the relationship between test and val. Like your version:

if ("foo" in val) {
  console.log(val.foo);
} else {
  console.log(val.bar);
}

Along the same vein, you could refactor wrap() so that its output is a true discriminated union where test is actually stored in the output:

const discrimWrap = <T extends boolean>(val: number, test: T): (
  T extends true ? { test: T, foo: number } : { test: T, bar: number }) => {
  if (test) {
    return { test, foo: val, } as any;
  }
  return { test, bar: val } as any;
}

Now instead of caring about the variable named test, you can use the test property of the wrapped output:

const discrimVal = discrimWrap(12, test);
if (discrimVal.test) {
  console.log(discrimVal.foo);
} else {
  console.log(discrimVal.bar);
}

Discriminated unions are one of the few places in TypeScript where it actually attempts to keep track of correlations. But they only work in specific circumstances, where the discriminant is a property of the object (and not a separate variable) and where it is a "singleton" or literal type (and not a wide type like string or number). And so this would be the refactoring I'd suggest here.


Finally, I would say that the "purpose of the conditional types in the function definition", as intended by the language designers, is not to be used to discriminate a test of a union type like boolean. When you pass a union in, a union comes out.

The purpose is to narrow the result type if the second argument is known by the compiler to be true or false via control flow analysis:

wrap(21, true).foo; // okay
wrap(21, false).bar; // okay
test ? wrap(21, test).foo : wrap(21, test).bar; // okay

That last line works because the compiler can narrow test to true and to false in each of the last two operands of the ternary expression, respectively. But if you call wrap(21, test) in a context where test is only known to be boolean, then the compiler loses any correlation.


Playground link to code

Upvotes: 2

Harley Thomas
Harley Thomas

Reputation: 454

Does this implementation solve your issue?

function wrap(val: number, foo: true): { foo: number }
function wrap(val: number, foo: false): { bar: number }
function wrap(val: number, foo: boolean): { foo: number } | { bar: number } {
    if (foo) return { foo: val }
    if (bar) return { bar: val }
}

When calling,

wrap(2, true)  // returns { foo: 2 }
wrap(2, false) // returns { bar: 2 }

Upvotes: 0

Related Questions