The Wayback Machine - https://web.archive.org/web/20201209155830/https://github.com/microsoft/TypeScript/issues/36887
Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Typeguard method not working for class members #36887

Open
AlCalzone opened this issue Feb 20, 2020 · 6 comments
Open

Typeguard method not working for class members #36887

AlCalzone opened this issue Feb 20, 2020 · 6 comments

Comments

@AlCalzone
Copy link

@AlCalzone AlCalzone commented Feb 20, 2020

TypeScript Version: 3.8-Beta (on Playground)

Search Terms: typeguard class member

Code

class Foo {
  constructor(prop: number | number[]) {
    this.prop = prop;
  }
  prop: number | number[];

  isNumberFoo(): this is NumberFoo {
    return typeof this.prop === "number";
  }

  isArrayFoo(): this is ArrayFoo {
    return Array.isArray(this.prop);
  }

  method() {
    // using typeguard methods
    if (this.isNumberFoo()) {
      this.prop; // (✓) correctly narrowed to number, but the type display is unnecessarily complicated
    } else {
      this.prop; //  ✗  not narrowed at all, but TS should know that prop is not a number
    }

    // using direct typeguards
    if (typeof this.prop === "number") {
      this.prop; // ✓ number
    } else {
      this.prop; // ✓ number[]
    }
  }
}

type NumberFoo = {
  prop: number;
}

type ArrayFoo = {
  prop: number[];
}

Expected behavior:
In the if branch, prop is narrowed to number. In the else branch, prop is narrowed to number[].

Actual behavior:
In the if branch, prop is narrowed to number & (number | number[]), which works but looks confusing.
In the else branch, prop is not narrowed at all.

Playground Link: here

Related Issues: #26212 describes the exact same issue, but was closed without a proper solution.

@RyanCavanaugh
Copy link
Member

@RyanCavanaugh RyanCavanaugh commented Feb 20, 2020

this isn't a union, so we don't have any available type to narrow to it in the else branch. This isn't something CFA is capable of producing without some notion of negated types

@AlCalzone
Copy link
Author

@AlCalzone AlCalzone commented Feb 21, 2020

Could it work if there was a way to narrow the property directly instead of the this type? Something along the lines of

  isNumberFoo(): this["prop"] is number {
    return typeof this.prop === "number";
  }
@mcintyret
Copy link

@mcintyret mcintyret commented Aug 6, 2020

Could there be some syntax like

isNumberFoo(): this is NumberFoo else this is ArrayFoo {
    return typeof this.prop === "number";
}

That lets the programmer explicitly set the "else" type?

I guess if that existed, the compiler could just do it automatically:

isTypeX(): this is TypeX else this is Exclude<typeof this, TypeX>;

@mcintyret
Copy link

@mcintyret mcintyret commented Aug 6, 2020

I'm hitting this issue in a related situation where the generic type is a union:

class Box<T> {
    constructor(private val: T) {}

    public containsArray(): this is Box<Extract<T, any[]>> {
        return Array.isArray(this.val);
    }

    get value(): T {
        return this.val;
    }
}

const box: Box<string | string[]> = new Box("foo");

// Type '(string | string[])[]' is not assignable to type 'string[]'.
//   Type 'string | string[]' is not assignable to type 'string'.
//     Type 'string[]' is not assignable to type 'string'.ts(2322)
const stringArray: string[] = box.containsArray() ? box.value : [box.value];
@oliver-giersch
Copy link

@oliver-giersch oliver-giersch commented Oct 5, 2020

I have come across this issue while trying to implement a Rust-like Result type, i.e., a discriminated union with two variants (Ok and Err) and a number of methods, directly callable on the Result instance via the dot operator.

const type Ok<T> = { tag: 'ok', val: T }
const type Err<E> = { tag: 'err', err: E }

export class Result<T, E> {
    private constructor(readonly repr: Ok<T> | Err<E>) { }
    static Ok<T, E>(val: T): Result<T, E> { ... }
    static Err<T, E>(val: E): Result<T, E> { ... }
    isOk(): this is { readonly repr: Ok<T> } { ... }
    isErr(): this is { readonly repr: Err<E> } { ... }
    getVal(this: { readonly repr: Ok<T> }: T { ... }
    getErr(this: { readonly repr: Err<E> }: E { ... }
    // various adapters like map, mapErr, and, or, etc...
}

This should allow writing code like so:

type AppError = { reason: IoError | ... }
declare function read_file(path: string): Result<Buffer, IoError>

function do_something(): Result<void, AppError> {
    const res = read_file(...)
    if (res.isErr()) {
        // narrowed to { repr: Err<E> }
        return Result.Err({ reason: res.getErr() })
    }
    // else branch, narrowed to { repr: Ok<T> } <-- this does not occurr!
    const file = res.getVal()
    // ...
}

I've tried a different approach defining Result like this:

export type Result<T, E> = (Ok<T> | Err<E>) & ResultMethods<T, E>
export interface ResultMethods<T, E> {
    isOk(): this is Ok<T>,
    isErr(): this is Err<E>,
    // ...etc
}

This feels unnecessarily convoluted though, because each result instance needs to have a bunch of arrow functions created along-side it each time it is constructed.
To me it seems that such thin wrapper classes/types (i.e., no base class, only a single non-function member) should be treated exhaustively by type guard checks.

@mlhaufe
Copy link

@mlhaufe mlhaufe commented Dec 9, 2020

A simple example of this problem:

abstract class Color {
    isRed(): this is Red { return false }
    isBlue(): this is Blue { return false }
    isGreen(): this is Green { return false}
}

class Red extends Color {
    isRed(): this is Red { return true }
}

class Green extends Color {
    isGreen(): this is Green { return true }
}

class Blue extends Color {
    isBlue(): this is Blue { return true }
}

let c: Color = new Blue()

if(c.isRed()) {
    c // c: Red
} else if(c.isGreen()) { // Property 'isGreen' does not exist on type 'never'
    // c: never (Green expected)
} else if(c.isBlue()) { // Property 'isBlue' does not exist on type 'never'
    // c: never (Blue expected)
}

c // c: Red (Blue expected. if all conditions false, then Color expected)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Linked pull requests

Successfully merging a pull request may close this issue.

None yet
5 participants
You can’t perform that action at this time.