vuejs / vue

This is the repo for Vue 2. For Vue 3, go to https://github.com/vuejs/core
http://v2.vuejs.org
MIT License
207.97k stars 33.68k forks source link

Function-type props broken in TypeScript #9357

Open kjleitz opened 5 years ago

kjleitz commented 5 years ago

Version

2.5.22

Reproduction link

https://jsfiddle.net/keegan_openbay/gehkx7pf/10/
https://jsfiddle.net/keegan_openbay/018rs3ae/11/

(More explanation in the fiddle, but keep in mind that JSFiddle doesn't show TS errors)

Steps to reproduce

  1. Declare a prop of type Function, and with a default function that returns some value; e.g.,
// ...
  props: {
    fooFn: {
      type: Function,
      default: () => true,
    },
  },
// ...
  1. Try to use that function elsewhere in your component options; e.g.,
// ...
  methods: {
    useFooFn(): void {
      const bar = this.fooFn();
      // ...
    },
  },
// ...

What is expected?

type FooFn = typeof this.fooFn; // Function
this.fooFn(); // no errors

What is actually happening?

type FooFn = typeof this.fooFn; // boolean | Function
this.fooFn();
// Cannot invoke an expression whose type lacks a call signature.
// Type 'boolean | Function' has no compatible call signatures.

Vue version: 2.5.22 TypeScript version: 3.0.3

tsconfig.json:

{
  "compilerOptions": {
    "declaration": false,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "lib": ["es7", "dom"],
    "module": "es2015",
    "moduleResolution": "node",
    "sourceMap": true,
    "target": "es5",
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "baseUrl": "./app/javascript",
    "noImplicitThis": true
  },
  "include": [
    "app/javascript/**/*.ts",
    "app/javascript/**/*.tsx",
    "app/javascript/**/*.vue"
  ],
  "exclude": [
    "**/*.spec.ts",
    "node_modules"
  ],
  "compileOnSave": false
}
posva commented 5 years ago

I tried adding the test case to the project and couldn't reproduce:

Vue.extend({
  props: {
    isValid: {
      type: Function,
      default: () => true,
    }
  },
  methods: {
    useFooFn(): void {
      const bar = this.isValid()
      alert(bar)
    }
  }
});
kjleitz commented 5 years ago

@posva Are you using the same TypeScript setup and seeing no compile errors?

posva commented 5 years ago

no, I'm using the one we have in the repo

kjleitz commented 5 years ago

Can you try with the setup I posted?

kjleitz commented 5 years ago

@posva

screen shot 2019-01-23 at 3 45 55 pm

screen shot 2019-01-23 at 3 45 12 pm

kjleitz commented 5 years ago

Found a clue:

I just downgraded vue (and vue-template-compiler) to 2.5.17 and it works fine, as it used to. Then I upgraded both to 2.5.18, and now I see a bunch of compiler errors (including this one) which had never occurred before:

// for Function-type props with a default like `() => false`, `(arg: number) => false`, etc.:
// => TS2349: Cannot invoke an expression whose type lacks a call signature. Type 'boolean | Function' has no compatible call signatures.
// (same error occurs with any return value)

function foo(barEl: HTMLElement) { /* ... */ }
foo(this.$el);
// => TS2345: Argument of type 'Element' is not assignable to parameter of type 'HTMLElement'.

const bar = this.$el.innerText;
// => TS2339: Property 'innerText' does not exist on type 'Element'.

Seems that:

  1. Function props' types are being recognized as either Function or the return type of their default function
  2. The type of this.$el has become Element instead of HTMLElement
  3. This occurs in vue/vue-template-compiler 2.5.18+, but not 2.5.17

~It may have something to do with "noImplicitThis": true in tsconfig.json; setting it to false removes all those errors (there is one new error in our project after setting it to false but it's to do with a lack of type inference on this.$store, all in one component rather than scattered across the project)~

kjleitz commented 5 years ago

Reverting https://github.com/vuejs/vue/pull/8537, specifically this change:

diff --git a/types/options.d.ts b/types/options.d.ts
index cc58affe6a..25eb8a0fdf 100644
--- a/types/options.d.ts
+++ b/types/options.d.ts
@@ -133,7 +133,7 @@ export type PropValidator<T> = PropOptions<T> | Prop<T> | Prop<T>[];
 export interface PropOptions<T=any> {
   type?: Prop<T> | Prop<T>[];
   required?: boolean;
-  default?: T | null | undefined | (() => object);
+  default?: T | null | undefined | (() => T | null | undefined);
   validator?(value: T): boolean;
 }

...fixes the Function-type prop issue.

kjleitz commented 5 years ago

And reverting https://github.com/vuejs/vue/pull/8809, specifically this change:

diff --git a/types/vue.d.ts b/types/vue.d.ts
index 44a892ead3..3832f2c9e4 100644
--- a/types/vue.d.ts
+++ b/types/vue.d.ts
@@ -21,7 +21,7 @@ export interface CreateElement {
 }

 export interface Vue {
-  readonly $el: HTMLElement;
+  readonly $el: Element;
   readonly $options: ComponentOptions<Vue>;
   readonly $parent: Vue;
   readonly $root: Vue;

...fixes the this.$el defaulting to Element issue.

kjleitz commented 5 years ago

I'll see if I can write up a PR for a fix without resurfacing the original issues those PRs were trying to solve.

kjleitz commented 5 years ago

Aaaaand I've realized over the past few days that I am not good enough with TypeScript to figure out how to do this.

Where...

  1. "noImplicitThis": true in tsconfig.json, and
  2. Vue version is 2.5.18 and above,

...in order to get a prop definition such as this:

// ...
  props: {
    isValid: {
      type: Function,
      default: () => true,
    }
  },
// ...

...to yield a type of:

this.isValid //=> Type: () => boolean

...instead of:

this.isValid //=> Type: boolean | () => boolean

You'd have to edit the PropOptions interface such that:

export interface PropOptions<T=any> {
  type?: Prop<T> | Prop<T>[];
  required?: boolean;
  // default?: T | null | undefined | (() => T | null | undefined);
  // I guess...?
  default?: Function extends T ? (T | null | undefined) : (T | null | undefined | (() => T | null | undefined));
  validator?(value: T): boolean;
}

Unfortunately, that example doesn't work, and the types of other properties on Vue are lost. I've tried a lot of different things over the past few days, but clearly I don't have the expertise to understand exactly how to fix the issue.

I believe, currently, a Function-type prop is a unique case. It is (correct me if I'm wrong), the only prop type that does not have the option of a default "factory" function:

props: {
  fnProp1: {
    type: Function,
    default: () => false, // type of this.fnProp1 should be `() => boolean`
  },
  fnProp2: {
    type: Function,
    default: () => (() => false), // type of this.fnProp2 should be `() => (() => boolean)`
  },
  boolProp1: {
    type: Boolean,
    default: false, // type of this.boolProp1 should be `boolean`
  },
  boolProp2: {
    type: Boolean,
    default: () => false, // type of this.boolProp2 should STILL be `boolean`
  },
  strProp1: {
    type: String,
    default: 'hi', // type of this.strProp1 should be `string`
  },
  strProp2: {
    type: String,
    default: () => 'hi', // type of this.strProp2 should STILL be `string`
  },
  // etc.
},
kjleitz commented 5 years ago

Furthermore, if you want to return an object from the default, it completely skips the function type altogether:

// ...

  props: {
    returnsAnObject: {
      type: Function,
      default: () => ({}),
    }
  },

// ...

  // Type SHOULD be `Function`, or `() => {}`, but...
  this.returnsAnObject; //=> Type: {}
  // ...which is not even the [broken] union `{} | () => {}` type like the other cases

  this.returnsAnObject();
  // Cannot invoke an expression whose type lacks a call signature.
  // Type '{}' has no compatible call signatures.

// ...
kjleitz commented 5 years ago

I'd rather not keep bumping this unnecessarily, since it's mostly an echo chamber at the moment, but this 2.5.17 to 2.5.18+ patch update breaks our build, necessitates a lot of boilerplate around what used to be correctly-inferred properties on our components, and the causal changes seem to be fairly clear. Any attention or help would be greatly appreciated!

pikax commented 5 years ago

if you annotate with the PropType<> it should work, this was a fix on https://github.com/vuejs/vue/pull/9733

const Example = Vue.extend({
    template: `
    <button @click="doSomethingWithFoo()">
        <slot></slot>
    </button>
  `,

  props: {
    // original issue
    fooFn: {
      type: Function as PropType<()=>string>,
      default: () => { return 'hey this is the default return value'; },
    },

     returnsAnObject: {
      type: Function as PropType<()=>object>,
      default: () => ({}),
    }
  },

  methods: {
    doSomethingWithFoo(): void {
      const obj = this.returnsAnObject(); //obj is object
        const bar = this.fooFn(); // bar is string

      alert(bar);
    },
  },
});

there's an PR https://github.com/vuejs/vuejs.org/pull/2068 to update docs

romansp commented 5 years ago

Is this issue back on TypeScript 3.6? The following compiles fine on TS 3.5.3 and fails on the latest TS 3.6.3.

Vue: 2.6.10 TypeScript: 3.6.3

import Vue from 'vue';

export default Vue.extend({
  props: {
    cb: {
      type: Function,
      default: () => {},
    },
  },

  created() {
    this.cb();
  }
});
13:10 This expression is not callable.
  No constituent of type 'void | Function' is callable.
    11 | 
    12 |   created() {
  > 13 |     this.cb();
       |          ^
    14 |   }
    15 | });

Removing default: () => {} from cb as well as annotating it with PropOptions<() => void> helps, but this wasn't needed before.

Put a repro here: https://github.com/romansp/vue-typescript-prop-function-default.

May be related to #10455.

kjleitz commented 5 years ago

@romansp I'm fairly confident that hasn't worked without annotation since Vue 2.5.17. Annotating with PropType<...> (note: not PropOptions<...>) works just fine on Vue 2.6.10 and TypeScript 3.6.3, though.

romansp commented 5 years ago

@kjleitz I'm sure that it does work on TS 3.5.3 and Vue 2.6.10. You can try cloning my repro https://github.com/romansp/vue-typescript-prop-function-default. I just pushed ts-3.5.3 branch where vue serve runs fine.

kjleitz commented 5 years ago

@romansp Ah, I see, you're not using the same tsconfig.json as in my original example. The fact that you're using "strict": true instead of "noImplicitThis": true fixes it in your ts-3.5.3 branch (we've also switched to using "strict": true since the time this ticket was written; much better!). Even with "strict": true though, if you set Vue back to v2.6.8 it's actually still broken in your ts-3.5.3 branch. It's always been wonky.

Vue v2.6.10 & TS v3.5.3 must be one of those special combinations that don't error for function props 🤷‍♂ But even in that branch, the "working" case loses type info from this.cb—better to use type: Function as PropType<() => void>, instead of a bare type: Function,.

kjleitz commented 4 years ago

This is still broken, even with "strict": true; can't use a default for a function-type prop. A more complete example:

const ComponentWithFunctionProps = Vue.extend({
  props: {
    functionProp: {
      type: Function,
      default: () => true,
    },
    functionPropWithBooleanReturnType: {
      type: Function as PropType<() => boolean>,
      default: () => true,
    },
    booleanProp: {
      type: Boolean,
      default: true,
    },
    booleanPropWithFunctionDefault: {
      type: Boolean,
      default: () => true,
    },
  },
  methods: {
    test(): void {
      // ERROR!
      // (property) functionProp: boolean | Function
      // -------------------------------------------
      // This expression is not callable.
      //   No constituent of type 'boolean | Function' is callable.ts(2349)
      this.functionProp();

      // ERROR!
      // (property) functionPropWithBooleanReturnType: boolean | (() => boolean)
      // -----------------------------------------------------------------------
      // This expression is not callable.
      //   Not all constituents of type 'boolean | (() => boolean)' are callable.
      //     Type 'false' has no call signatures.ts(2349)
      this.functionPropWithBooleanReturnType();

      // const foo: boolean
      const foo = this.booleanProp;

      // const bar: boolean
      const bar = this.booleanPropWithFunctionDefault;
    },
  },
});

I submitted a fix for this in https://github.com/vuejs/vue/pull/11223.