Home > front end >  Type guard unsoundly narrows union type in else branch
Type guard unsoundly narrows union type in else branch

Time:08-28

I've noticed type guards applied to union types narrow the else branch in a potentially unsound way.

const isShortString = (value: any): value is string => {
  return typeof value === 'string' && value.length < 10;
}

const fn1 = (value: string | number) => {
   if (isShortString(value)) {
      // ✅ Correctly narrowed to string
      return value.trim();
   } else {
      // ❌ Incorrectly narrowed to `number` (This could still be a string with length < 10)
      return value.toExponential();
   }
}

TS handles it well with native type guards; or I guess more accurately when checking a "type guard" && "some other condition".

const fn2 = (value: string | number) => {
   if (typeof value === 'string' && value.length > 10) {
      // ✅ Correctly narrowed to `string`
      return value.trim();
   } else {
      // ✅ Correctly remains `string | number`
      return value.toExponential(); // Error
   }
}

Is there a way to express that isShortString shouldn't narrow the else branch or is this a misuse of type guards?

TypeScript Playground

CodePudding user response:

You're looking for "one-sided" user-defined type guard funtions, as requested in microsoft/TypeScript#15048.

TypeScript doesn't currently support negated types (as implemented but never merged in microsoft/TypeScript#29317) of the form not T. But the intent of user-defined type guard functions whose return type is a type predicate of the form v is T is that a true return value should narrow v from its current type V to something like the intersection V & T, while a false return value should narrow v from its current type V to V & not T. That is, these type guard functions are supposed to split V into the piece compatible with T and the piece that isn't.

So a function whose type is (value: any) => value is string should really be used to determine if value is or is not a string. Hence you are misusing type guard functions.


If TypeScript ever directly supports "one-sided" functions, maybe you could write (value: any) => (true & (value is string)) | false. Currently, though, since TypeScript lacks negated types, you can get similar behavior by changing your guarded type to something where there's no reasonable way to represent its negation. With union types like string | number, it's easy to split it into string or number. But if you don't split at union boundaries, then the remainder is unrepresentable in TypeScript, and the compiler gives up on the false case. For example, if you write value is string & {prop: 123}, then the true case can narrow from string | number to string & {prop: 123}. But the false case can't narrow at all. There's no number | (string & not {prop: 123}) we can narrow to. So it's still string | number. This approach is mentioned in this comment.

You could just use string & {randomFakeProp: any}. But in your case there is a real structural difference between a "short" string and a regular one; the length property. So you can write out something vaguely correct like this:

const isShortString = (
    value: any
): value is string &
{ length: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 } => {
    return typeof value === 'string' && value.length < 10;
}

After all, a short string has a length of some number between 0 and 9, and we can write that as a union. Now things behave more as desired:

const fn1 = (value: string | number) => {
    if (isShortString(value)) {
        return value.trim();
    } else {
        return value.toExponential(); // error!
    }
}

Playground link to code

  • Related