Skip to content

Type-defined, fat-arrow function type guards do not compile #14826

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

Closed
chriskrycho opened this issue Mar 23, 2017 · 5 comments
Closed

Type-defined, fat-arrow function type guards do not compile #14826

chriskrycho opened this issue Mar 23, 2017 · 5 comments

Comments

@chriskrycho
Copy link

chriskrycho commented Mar 23, 2017

Currently, the behavior of type guards is asymmetric between function declarations and arrow-function declarations.

TypeScript Version: 2.2.1

Code

This works:

type A = { a: boolean };
type B = { b: string };

type ToCheck = A | B | A & B;

function hasA(arg: ToCheck): arg is A | A & B {
  return arg.hasOwnProperty('a');
}

function hasB(arg: ToCheck): arg is B | A & B {
  return arg.hasOwnProperty('b');
}

Expected behavior:

So should this:

type A = { a: boolean };
type B = { b: string };

type ToCheck = A | B | A & B;

type HasA = (arg: ToCheck) => arg is A | A & B;
const hasA: HasA = arg => arg.hasOwnProperty('a');

type HasB = (arg: ToCheck) => arg is B | A & B;
const hasB: HasB = (arg) => arg.hasOwnProperty('b');

Actual behavior:

The compiler reports:

Type '(arg: ToCheck) => boolean' is not assignable to type 'HasA'. Signature '(arg: ToCheck): boolean' must have a type predicate.

This is an unfortunate asymmetry. I prefer to use the type/const bindings throughout for various reasons, not least because it's extremely convenient for a type-driven design approach where I write the types out ahead of time and populate the fat-arrow function bindings later. E.g. in the case which motivated this, I did just that: I wrote out the equivalent of the HasA and HasB type definitions (along with a bunch of others), then followed up by writing the hasA and hasB bodies.

The anonymity of the function is not at issue; given either of the above definitions, this works just fine:

const hasBoth = function(arg: ToCheck): arg is A & B {
  return hasA(arg) && hasB(arg);
}

Unsurprisingly, the same limitation exist with interface types—this does not work, either:

interface HasBoth { (arg: ToCheck): arg is A & B }
const hasBoth: HasBoth = (arg) => hasA(arg) && hasB(arg);

It would be great if type guards could be generalized to work with the type-definition and interface forms.

@chriskrycho chriskrycho changed the title type foo =-style type guards do not compile type foo- and interface-style type guards do not compile Mar 23, 2017
@chriskrycho chriskrycho changed the title type foo- and interface-style type guards do not compile Type-defined, fat-arrow function type guards do not compile Mar 23, 2017
@RyanCavanaugh
Copy link
Member

The difference isn't about arrow functions vs regular function expressions/declarations; the only difference is that you have return type annotations in some places but not others:

// OK
type HasB = (arg: ToCheck) => arg is B | A & B;
const hasB: HasB = (arg): arg is B | A & B => arg.hasOwnProperty('b');

// Error
const hasA: HasA = function(arg: ToCheck) {
    return arg.hasOwnProperty('a');
}

Without some return type annotation it's pretty sketchy to assume any boolean-returning function expression is actually a type guard for some type.

@chriskrycho
Copy link
Author

chriskrycho commented Mar 23, 2017

I'm perplexed; this may be a failure of either what the docs say or my comprehension (and I'd be happy to write up a PR clarifying it once I actually understand this).

type HasB = (arg: ToCheck) => arg is B | A & B;
const hasB: HasB = (arg): arg is B | A & B => arg.hasOwnProperty('b');
                        ^-----------------^
                           what is this?

My reading of the docs suggested that these two were equivalent:

// inline definition
const foo = (arg: number): string => arg.toString();

type Bar = (arg: number) => string;
const bar: Bar = (arg) => arg.toString();

Your response indicates that's not the case; I'm not sure what the difference is between the two is. And that seems to be the root of my confusion here.

To clarify: my mental model was that the use of the type ascription would inform the compiler in the same way that the general function type definition does, because I took the two as being substitutable/equivalent (leaving aside details about this binding in terms of function of course).

Allow me to ask the question another way: what exactly does this mean?

type HasA = (arg: ToCheck) => arg is A | A & B;
const hasA: HasA = (arg: ToCheck): arg is A | A & B => arg.hasOwnProperty('a');

And: is there no way to define a type-guarding function without duplicating that type ascription (or just not doing the initial type-driven approach)?

Edit: I'll add: I understand that const foo: Foo is ascribing a type to foo and that the compiler then verifies that whatever is bound to it matches or not. My assumption—apparently incorrect—was that the inference would see "oh, type guards are of necessity boolean-returning functions; this is a boolean-returning function being assigned to a type guard; cool." It seems that's where the breakdown is?

@RyanCavanaugh
Copy link
Member

Pretty much a duplicate of #5951 but I'll post another longer comment while you read that

@RyanCavanaugh
Copy link
Member

My reading of the docs suggested that these two were equivalent

They're very close. If you just had a naked function expression arg => arg.toString(), then arg would have the implicit type any. But because the expression occurs in a context where we have an expected type (the declared type of the variable it's initializing), we contextually type the function expression. Contextual typing mostly affects parameters -- in this case, arg gets a contextual type of string from the matching parameter of the contextual type (arg: string) => string.

The end result for most types is not observably different, but contextual typing will not "downcast" a return type to a more-specific type (in this case, boolean to arg is T). Since there's no way to write a normal expression in such a way that it produces a type predicate, the return type (or a type assertion of the returned value) is the only way to produce the "correct" return type to match the declared type of the variable.

is there no way to define a type-guarding function without duplicating that type ascription (or just not doing the initial type-driven approach)?

If you wanted to write this without duplicating things, you could simply write:

const hasA = (arg: ToCheck): arg is A | A & B => arg.hasOwnProperty('a');
type HasA = typeof hasA;

@chriskrycho
Copy link
Author

This all makes sense. It's frustrating, but it makes sense, and I appreciate both the earlier-issue-link and your careful explanation here.

(The frustration is because your offered solution doesn't actually address my approach at all: the concern is type-driven development, not having a type available when all is said and done. That you can do typeof hasA is great in general, but it's totally useless for that specific task, and it equally doesn't help diminish the incredible noisiness of the required declaration.)

Sorry to duplicate the earlier issue; I went looking but both my Google and my GitHub searches failed to turn it up. Does this appear in the docs anywhere? If not, is there somewhere I should open a PR/edit a wiki? Otherwise, I can just write this up as a blog post. I just want a canonical, easy-to-find explanation all in one place for other folks perplexed by this. 😄 Thanks again for the careful explanation!

@microsoft microsoft locked and limited conversation to collaborators Jun 21, 2018
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants