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

Allow method type guards #11117

Open
vagarenko opened this issue Sep 24, 2016 · 10 comments
Open

Allow method type guards #11117

vagarenko opened this issue Sep 24, 2016 · 10 comments
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript

Comments

@vagarenko
Copy link

TypeScript Version: 2.0.3

Code

Why I can have function type guard:

function hasValue<T>(value: T | undefined): value is T { return value !== undefined; }

but not method type guard:

export class Maybe<T> {
    constructor(public value: T | undefined) {}

    hasValue(): this.value is T { return this.value !== undefined; }
    //              ^ '{' or ';' expected.
}

?

@mhegazy mhegazy added Suggestion An idea for TypeScript Needs Proposal This issue needs a plan that clarifies the finer details of how it could be implemented. labels Sep 24, 2016
@kitsonk
Copy link
Contributor

kitsonk commented Sep 24, 2016

Semi related to a random thought here in that more complicated guards could express codependency of properties:

interface Array<T> {
    length: number;
    pop(): this.length > 0 ? T : undefined;
}

const arr = [ 1, 2, 3 ];

if (arr.length > 0) {
    const val = arr.pop(); // val is number, not number | undefined under strictNullChecks
}

@aluanhaddad
Copy link
Contributor

aluanhaddad commented Sep 24, 2016

This would be interesting... expressing path dependent types.
Read the OP backward... too early.

@mattmazzola
Copy link
Member

Would this also fix issues with Map?
In the case where you're using map to add memoization to functions there is a similar need to use the ! operator.

const cache = new Map<number, string[]>()
if (cache.has(n)) {
  return cache.get(n)!  // The if statement above should make return type `string[]` instead of `string[] | undefined`
}

@DanielRosenwasser
Copy link
Member

DanielRosenwasser commented Jul 12, 2017

What about using this type guards instead?

export class Maybe<T> {
    constructor(public value: T | undefined) {}

    hasValue(): this is this & { value: T } { return this.value !== undefined; }
}

///////

declare var x: Maybe<string>;
if (x.hasValue()) {
    x.value.toLowerCase();
}

@mattmazzola I've used the above technique to describe a way that Map#has could work similarly, but it can get a bit messy.

@mattmazzola
Copy link
Member

Ok, I'll have to learn/explore more about the this type guards. In my Map example this type definition is pre-defined by the lib.d.ts since they're part of ES2016 I believe and I didn't want to re-write my own type which wraps the native one.

@masaeedu
Copy link
Contributor

@DanielRosenwasser Any way to make that approach work for private properties?

@RyanCavanaugh RyanCavanaugh added Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature and removed Needs Proposal This issue needs a plan that clarifies the finer details of how it could be implemented. labels Jul 29, 2019
@sgronblo
Copy link

I wanted to use this for this pattern:

https://www.typescriptlang.org/play/?ssl=14&ssc=1&pln=15&pc=1#code/KYDwDg9gTgLgBAYwDYEMDOa4BEU2ASQDs8oA3FJOAbwCg44woBLcvRCQtGKAVwRmgAKRi1zA4XFLABc2MQBoGzVuOCEAJrJxsAPnEI8kSAJTUAvjTpwmaAPKEA5hCaPBx2TAAWN65i8+AMmo4NU19Q0ozait6KGAYHihCOH80ADpQuABeHPCjKwsrGwAxFyY8Nw9vTB9UuCCqEI0tMTgo2npY+MTk1IyNOABCXIN8+kL6AVLCcuAAZW4XBzdozusAMzhBPpKyiuNTDrW4OISkuAADABIqPslYNIEF5ldjKIBaOBu+0MeIZ6WbjMFxibVBXigEAA7vpgDCAKJQSFQQQAcgAKtU5HgiCRyJQfBwnEtFAgUIRCBB4AgOKRgLAUhA4Os9uIXHiKBJFo5UcYCjQzEA

export class DateInterval {
  private constructor(private start: Date, private end: Date | null) {}

  isOngoing(): this is this & { end: null } {
    return this.end === null
  }

  isFinite(): this is this & { end: Date } {
    return this.end !== null
  }

  toFiniteString() {
    if (this.isFinite()) {
      return `${this.start.toString()} - ${this.end.toString()}`
    }
    throw new Error('This DateInterval is ongoing, cannot convert to finite interval string')
  }
}

But it gives me the following error: "Property 'end' has conflicting declarations and is inaccessible in type 'DateInterval & { end: Date; }'.(2546)"

@rodrigost23
Copy link

The problem with the solution by @DanielRosenwasser:

What about using this type guards instead?

export class Maybe<T> {
    constructor(public value: T | undefined) {}

    hasValue(): this is this & { value: T } { return this.value !== undefined; }
}

///////

declare var x: Maybe<string>;
if (x.hasValue()) {
    x.value.toLowerCase();
}

is that "else" doesn't work:

if (x.hasValue()) {
    x.value.toLowerCase();
}
else {
  x.value // x.value is string | undefined instead of undefined
}

Is there any way to negate the hasValue() function and get undefined, in this case?

@devanshj
Copy link

devanshj commented Aug 5, 2021

Is there any way to negate the hasValue() function and get undefined, in this case?

Yep, here's a better version -

type Maybe<T> =
  & ( Just<T>
    | Nothing
    )
  & MaybePrototype<T>

interface Just<T> { value: T }
interface Nothing { value: undefined }
interface MaybePrototype<T> { isJust(this: Just<T> | Nothing): this is Just<T> }

type MaybeConstructor =
  new <T>(value: T | undefined) => Maybe<T>

const Maybe = (class MaybeImpl<T> {
  constructor(public value: T | undefined) {}
  isJust() { return this.value !== undefined }
}) as MaybeConstructor

const x = new Maybe("");
if (x.isJust()) {
  x; // Just<string>
  x.value // string
  x.value.toLowerCase();
} else {
  x; // Nothing
  x.value; // undefined
  let test: undefined = x.value;
}

@devanshj
Copy link

devanshj commented Aug 5, 2021

(only for sake of showing some stuff off, don't take it seriously :P)

In a language like TypeScript it's better to write abstractions in something like static-land. If at all one wants to consume it in the class form I wrote a little thing called classFromStaticLand that converts a static land implementation to object-oriented implementation. Here's how you'd use it (taking @sgronblo's example)

type DateInterval =
  | { start: Date, end: Date }
  | { start: Date, end: null }
const _DateInterval = {
  of: (start: Date, end: Date | null) =>
    ({ start, end }) as DateInterval,
  isOngoing: (t: DateInterval): t is DateInterval & { end: null } =>
    t.end === null,
  isFinite: (t: DateInterval): t is DateInterval & { end: Date } =>
    !_DateInterval.isOngoing(t),
  toFiniteString: (t: DateInterval) => {
    if (_DateInterval.isFinite(t)) {
      return `${t.start.toString()} - ${t.end.toString()}` // no error
    }
    throw new Error("This DateInterval is ongoing, cannot convert to finite interval string")
  }
}
const DateInterval = classFromStaticLand(_DateInterval, "DateInterval");

let x = new DateInterval(new Date(), new Date());
console.log(x)
  
if (x.isOngoing()) {
  x.end // null;
  x.end.getDay() // expected error
} else {
  x.end // Date;
  console.log(x.end.getDay())
}

Here's what the console would look like...
image

Here's the playground link to try it out.

And here's the full code for reference
const main = () => {
  type DateInterval =
    | { start: Date, end: Date }
    | { start: Date, end: null }
  const _DateInterval = {
    of: (start: Date, end: Date | null) =>
      ({ start, end }) as DateInterval,
    isOngoing: (t: DateInterval): t is DateInterval & { end: null } =>
      t.end === null,
    isFinite: (t: DateInterval): t is DateInterval & { end: Date } =>
      !_DateInterval.isOngoing(t),
    toFiniteString: (t: DateInterval) => {
      if (_DateInterval.isFinite(t)) {
        return `${t.start.toString()} - ${t.end.toString()}` // no error
      }
      throw new Error("This DateInterval is ongoing, cannot convert to finite interval string")
    }
  }
  const DateInterval = classFromStaticLand(_DateInterval, "DateInterval");

  let x = new DateInterval(new Date(), new Date());
  console.log(x)

  if (x.isOngoing()) {
    x.end // null;
    x.end.getDay() // expected error
  } else {
    x.end // Date;
    console.log(x.end.getDay())
  }
}

const classFromStaticLand =
  ((m: any, name: any) =>
    function (this: any, ...a: any[]) {
      Object.assign(this, m.of(...a))
      Object.setPrototypeOf(
        this,
        Object.fromEntries(
          Object.entries(m)
          .filter(([k]) => k !== "of")
          .map(([k, f]) => [
            k,
            function (this: any, ...a: any[]) {
              return (f as any)(this, ...a);
            }
          ])
        )
      )
      this[Symbol.toStringTag] = name;
    }
  ) as any as FromStaticLand.Class

main();

namespace FromStaticLand {
  export type Class =
    <M extends { of: (...a: any[]) => object }>(m: M, name: string) =>
      new (...a: M["of"] extends (...a: infer A) => any ? A : never) =>
        Instance<M>

  type Instance<M extends { of: () => unknown }, T = ReturnType<M["of"]>> =
    & T
    & { [K in Exclude<keyof M, "of">]:
          IsGuard<M[K]> extends true
            ? Guard<T, M[K]>
            : Method<T, M[K]>
      }

  interface Method<T, F>
    { (this: T, ...a: Parameters<F>): Called<F>
    }

  interface Guard<T, F>
    { (this: T, ...a: Parameters<F>): this is GaurdApplied<F>
    }

  type IsGuard<F> = F extends (t: any) => t is any ? true : false
  type Parameters<F> = F extends (t: any, ...a: infer A) => any ? A : never;
  type Called<F> = F extends (t: any, ...a: any[]) => infer R ? R : never;
  type GaurdApplied<F> = F extends (t: any, ...a: any[]) => t is infer U ? U : never;
}

But more seriously one way to facilitate this is to allow typing this in the constructor like so...

class Maybe<T> {
  constructor(this: Just<T> | Nothing, public value: T | undefined) {}
  isJust(): this is Just<T> { return this.value !== undefined }
}
interface Just<T> { value: T }
interface Nothing { value: undefined }

if (x.isJust()) {
  x; // Just<string>
  x.value // string
} else {
  x; // Nothing
  x.value; // undefined
}

The problem is this...

type Maybe<T> = { value: T | undefined }
type Test1 = Maybe<string>
type Test1IfBranch = Test1 & { value: string } // { value: string }
type Test1ElseBranch = Exclude<Test1, Test1IfBranch> // { value: string | undefined } (no good)

type MaybeWithThisAnnotated<T> = { value: T } | { value: undefined }
type Test2 = MaybeWithThisAnnotated<string>
type Test2IfBranch = Test2 & { value: string } // { value: string }
type Test2ElseBranch = Exclude<Test2, Test2IfBranch> // { value: undefined } (nice)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests