Repository: yannickcr/eslint-plugin-react
Pull request #1209 (2017-05-19 13:10:54)

Add rule no static typos
My attempt to add this rule. It seemed pretty basic... so here it is! I am open to new ideas and improvements to the rule. Please let me know!

Issue: https://github.com/yannickcr/eslint-plugin-react/issues/1189

Currently it only checks for`static` class properties. And only for `defaultProps`, `contextTypes` and `propTypes`. It should be trivial to add new properties as well though.

Because it only checks for static properties, it means that having `PropTypes` as a non-static class property or a method definition is still allowed. Although honestly I do not know why anyone would do that? Perhaps the rule can be extended to also warn in case `propTypes` is not declared as a `static` class property?

The rule only works for React Components. Within classes that are not React components, anything is still possible.

jseminck: Tackled your comments. I was not aware of `childContextTypes` - it is not something I have had to use before. If there are any properties that should be added please let me know. 

It's pretty trivial to add them - just have to make sure the test cases are added as well.

Also I wanted to ask: as I was working on this, I thought that it might be worth it to detect small spelling mistakes as well? We could easily detect 1-letter spelling mistakes with Levenshtein distance algorithm. 
ljharb: Detecting spelling mistakes seems like a slippery slope.
jseminck: The default parser throws an error. I assume that is the case because the class properties proposal is still stage-0 and it is not supported by espree (which is the default parser). 

This is the error that the test also gives: https://astexplorer.net/#/gist/da19b693a7a3d48bcc3fb249180b5295/a5875bd2c21133f64ce9f397ff82adb5e37da26a

Please correct me if I am wrong, would need guidance what exactly needs to be changed. :)

I did remove the `require("babel-eslint");` at the top of the test file. That seems like it was not needed.
ljharb: Indeed, I'm talking about assigning it outside the `class` - this should apply to the currently-standard `Foo.propTypes = {}` as well as the not-yet-standard class properties form.
jseminck: I added tests and support for assignment outside the class.

But I cannot get it to work when assigning on a stateless function component: I am not sure how to detect whether the function is a stateless function component or just some random function yet.

```js
      function MyComponent(props) {
        return <div>{props.name}</div>;
      }

      QuestionList.PrOpTyPeS = {
        name: PropTypes.string.isRequired,
      };
```
ljharb: We have `Components.detect` used elsewhere in the project which should be able to handle that for you.
jseminck: I spent quite a bit of time on this but no luck so far. I cannot figure out how to properly detect a stateless function component so that the following becomes invalid:

```js
function MyComponent() { return (<div>{this.props.myprop}</div>) };
MyComponent.pRoPTyPeS = { myProp: PropTypes.string }
```

But the following is valid (since it is not a stateless component):

```js
function SomeRandomFunction() { return {} };
MyComponent.pRoPTyPeS = { }
```

I tried using `components.isReturningJSX` but didn't get anywhere.

I added all the test cases, maybe someone else has any idea?
ljharb: `Components.detect`?
jseminck: So I finally managed to fix this. I also rebased everything unto the latest master.

I ended up extending `Components.findReturnStatement` to also work when passing in ` FunctionDeclaration` node. Previously, it would not work for this specific node.

This way, I could now use `utils.isReturningJSX` to properly detect a functional stateless component compared to a normal function..

I tried various other approaches from the existing `Components` API but nothing wold make the tests pass as I expected them to.

Patch:
diff --git a/README.md b/README.md
index 2109a9a1..2dd2cddb 100644
--- a/README.md
+++ b/README.md
@@ -102,6 +102,7 @@ Finally, enable all of the rules that you would like to use.  Use [our preset](#
 * [react/no-redundant-should-component-update](docs/rules/no-redundant-should-component-update.md): Prevent usage of `shouldComponentUpdate` when extending React.PureComponent
 * [react/no-render-return-value](docs/rules/no-render-return-value.md): Prevent usage of the return value of `React.render`
 * [react/no-set-state](docs/rules/no-set-state.md): Prevent usage of `setState`
+* [react/no-typos](docs/rules/no-typos.md): Prevent common casing typos
 * [react/no-string-refs](docs/rules/no-string-refs.md): Prevent using string references in `ref` attribute.
 * [react/no-unescaped-entities](docs/rules/no-unescaped-entities.md): Prevent invalid characters from appearing in markup
 * [react/no-unknown-property](docs/rules/no-unknown-property.md): Prevent usage of unknown DOM property (fixable)
diff --git a/docs/rules/no-typos.md b/docs/rules/no-typos.md
new file mode 100644
index 00000000..a7dd2b92
--- /dev/null
+++ b/docs/rules/no-typos.md
@@ -0,0 +1,70 @@
+# Prevents common casing typos (react/no-typos)
+
+Ensure no casing typos were made declaring static class properties
+
+## Rule Details
+
+This rule checks whether the declared static class properties related to React components
+do not contain any typos. It currently makes sure that the following class properties have
+no casing typos:
+
+* propTypes
+* contextTypes
+* childContextTypes
+* defaultProps
+
+The following patterns are considered warnings:
+
+```js
+class MyComponent extends React.Component {
+  static PropTypes = {}
+}
+
+class MyComponent extends React.Component {
+  static proptypes = {}
+}
+
+class MyComponent extends React.Component {
+  static ContextTypes = {}
+}
+
+class MyComponent extends React.Component {
+  static contexttypes = {}
+}
+
+class MyComponent extends React.Component {
+  static ChildContextTypes = {}
+}
+
+class MyComponent extends React.Component {
+  static childcontexttypes = {}
+}
+
+class MyComponent extends React.Component {
+  static DefaultProps = {}
+}
+
+class MyComponent extends React.Component {
+  static defaultprops = {}
+}
+```
+
+The following patterns are not considered warnings:
+
+```js
+class MyComponent extends React.Component {
+  static propTypes = {}
+}
+
+class MyComponent extends React.Component {
+  static contextTypes = {}
+}
+
+class MyComponent extends React.Component {
+  static childContextTypes = {}
+}
+
+class MyComponent extends React.Component {
+  static defaultProps = {}
+}
+```
diff --git a/index.js b/index.js
index 3cb6f72f..7bd88043 100644
--- a/index.js
+++ b/index.js
@@ -65,7 +65,8 @@ const allRules = {
   'void-dom-elements-no-children': require('./lib/rules/void-dom-elements-no-children'),
   'jsx-tag-spacing': require('./lib/rules/jsx-tag-spacing'),
   'no-redundant-should-component-update': require('./lib/rules/no-redundant-should-component-update'),
-  'boolean-prop-naming': require('./lib/rules/boolean-prop-naming')
+  'boolean-prop-naming': require('./lib/rules/boolean-prop-naming'),
+  'no-typos': require('./lib/rules/no-typos')
 };
 
 function filterRules(rules, predicate) {
diff --git a/lib/rules/no-typos.js b/lib/rules/no-typos.js
new file mode 100644
index 00000000..d3cce137
--- /dev/null
+++ b/lib/rules/no-typos.js
@@ -0,0 +1,60 @@
+/**
+ * @fileoverview Prevent common casing typos
+ */
+'use strict';
+
+const Components = require('../util/Components');
+
+// ------------------------------------------------------------------------------
+// Rule Definition
+// ------------------------------------------------------------------------------
+
+const STATIC_CLASS_PROPERTIES = ['propTypes', 'contextTypes', 'childContextTypes', 'defaultProps'];
+
+module.exports = {
+  meta: {
+    docs: {
+      description: 'Prevent common casing typos',
+      category: 'Stylistic Issues',
+      recommended: false
+    },
+    schema: []
+  },
+
+  create: Components.detect(function(context, components, utils) {
+    function reportErrorIfCasingTypo(node, propertyName) {
+      STATIC_CLASS_PROPERTIES.forEach(function(CLASS_PROP) {
+        if (propertyName && CLASS_PROP.toLowerCase() === propertyName.toLowerCase() && CLASS_PROP !== propertyName) {
+          context.report({
+            node: node,
+            message: 'Typo in static class property declaration'
+          });
+        }
+      });
+    }
+
+    return {
+      ClassProperty: function(node) {
+        if (!node.static || !utils.isES6Component(node.parent.parent)) {
+          return;
+        }
+
+        const tokens = context.getFirstTokens(node, 2);
+        const propertyName = tokens[1].value;
+        reportErrorIfCasingTypo(node, propertyName);
+      },
+
+      MemberExpression: function(node) {
+        const relatedComponent = utils.getRelatedComponent(node);
+
+        if (
+          relatedComponent &&
+          (utils.isES6Component(relatedComponent.node) || utils.isReturningJSX(relatedComponent.node))
+        ) {
+          const propertyName = node.property.name;
+          reportErrorIfCasingTypo(node, propertyName);
+        }
+      }
+    };
+  })
+};
diff --git a/lib/util/Components.js b/lib/util/Components.js
index 31f634ea..1346fb9e 100644
--- a/lib/util/Components.js
+++ b/lib/util/Components.js
@@ -319,13 +319,19 @@ function componentRule(rule, context) {
      * @param {ASTNode} ASTnode The AST node being checked
      */
     findReturnStatement: function(node) {
-      if (!node.value || !node.value.body || !node.value.body.body) {
+      if (
+        (!node.value || !node.value.body || !node.value.body.body) &&
+        (!node.body || !node.body.body)
+      ) {
         return false;
       }
-      let i = node.value.body.body.length - 1;
+
+      const bodyNodes = (node.value ? node.value.body.body : node.body.body);
+
+      let i = bodyNodes.length - 1;
       for (; i >= 0; i--) {
-        if (node.value.body.body[i].type === 'ReturnStatement') {
-          return node.value.body.body[i];
+        if (bodyNodes[i].type === 'ReturnStatement') {
+          return bodyNodes[i];
         }
       }
       return false;


Test patch:
diff --git a/tests/lib/rules/no-typos.js b/tests/lib/rules/no-typos.js
new file mode 100644
index 00000000..78833524
--- /dev/null
+++ b/tests/lib/rules/no-typos.js
@@ -0,0 +1,371 @@
+/**
+ * @fileoverview Tests for no-typos
+ */
+'use strict';
+
+// -----------------------------------------------------------------------------
+// Requirements
+// -----------------------------------------------------------------------------
+
+const rule = require('../../../lib/rules/no-typos');
+const RuleTester = require('eslint').RuleTester;
+
+const parserOptions = {
+  ecmaVersion: 6,
+  ecmaFeatures: {
+    jsx: true
+  }
+};
+
+// -----------------------------------------------------------------------------
+// Tests
+// -----------------------------------------------------------------------------
+
+const ERROR_MESSAGE = 'Typo in static class property declaration';
+
+const ruleTester = new RuleTester();
+ruleTester.run('no-typos', rule, {
+
+  valid: [{
+    code: [
+      'class First {',
+      '  static PropTypes = {key: "myValue"};',
+      '  static ContextTypes = {key: "myValue"};',
+      '  static ChildContextTypes = {key: "myValue"};',
+      '  static DefaultProps = {key: "myValue"};',
+      '}'
+    ].join('\n'),
+    parser: 'babel-eslint',
+    parserOptions: parserOptions
+  },
+  {
+    code: [
+      'class First {}',
+      'First.PropTypes = {key: "myValue"};',
+      'First.ContextTypes = {key: "myValue"};',
+      'First.ChildContextTypes = {key: "myValue"};',
+      'First.DefaultProps = {key: "myValue"};'
+    ].join('\n'),
+    parserOptions: parserOptions
+  }, {
+    code: [
+      'class First extends React.Component {',
+      '  static propTypes = {key: "myValue"};',
+      '  static contextTypes = {key: "myValue"};',
+      '  static childContextTypes = {key: "myValue"};',
+      '  static defaultProps = {key: "myValue"};',
+      '}'
+    ].join('\n'),
+    parser: 'babel-eslint',
+    parserOptions: parserOptions
+  }, {
+    code: [
+      'class First extends React.Component {}',
+      'First.propTypes = {key: "myValue"};',
+      'First.contextTypes = {key: "myValue"};',
+      'First.childContextTypes = {key: "myValue"};',
+      'First.defaultProps = {key: "myValue"};'
+    ].join('\n'),
+    parserOptions: parserOptions
+  }, {
+    code: [
+      'class MyClass {',
+      '  propTypes = {key: "myValue"};',
+      '  contextTypes = {key: "myValue"};',
+      '  childContextTypes = {key: "myValue"};',
+      '  defaultProps = {key: "myValue"};',
+      '}'
+    ].join('\n'),
+    parser: 'babel-eslint',
+    parserOptions: parserOptions
+  }, {
+    code: [
+      'class MyClass {',
+      '  PropTypes = {key: "myValue"};',
+      '  ContextTypes = {key: "myValue"};',
+      '  ChildContextTypes = {key: "myValue"};',
+      '  DefaultProps = {key: "myValue"};',
+      '}'
+    ].join('\n'),
+    parser: 'babel-eslint',
+    parserOptions: parserOptions
+  }, {
+    code: [
+      'class MyClass {',
+      '  proptypes = {key: "myValue"};',
+      '  contexttypes = {key: "myValue"};',
+      '  childcontextypes = {key: "myValue"};',
+      '  defaultprops = {key: "myValue"};',
+      '}'
+    ].join('\n'),
+    parser: 'babel-eslint',
+    parserOptions: parserOptions
+  }, {
+    code: [
+      'class MyClass {',
+      '  static PropTypes() {};',
+      '  static ContextTypes() {};',
+      '  static ChildContextTypes() {};',
+      '  static DefaultProps() {};',
+      '}'
+    ].join('\n'),
+    parser: 'babel-eslint',
+    parserOptions: parserOptions
+  }, {
+    code: [
+      'class MyClass {',
+      '  static proptypes() {};',
+      '  static contexttypes() {};',
+      '  static childcontexttypes() {};',
+      '  static defaultprops() {};',
+      '}'
+    ].join('\n'),
+    parser: 'babel-eslint',
+    parserOptions: parserOptions
+  }, {
+    code: [
+      'class MyClass {}',
+      'MyClass.prototype.PropTypes = function() {};',
+      'MyClass.prototype.ContextTypes = function() {};',
+      'MyClass.prototype.ChildContextTypes = function() {};',
+      'MyClass.prototype.DefaultProps = function() {};'
+    ].join('\n'),
+    parserOptions: parserOptions
+  }, {
+    code: [
+      'class MyClass {}',
+      'MyClass.PropTypes = function() {};',
+      'MyClass.ContextTypes = function() {};',
+      'MyClass.ChildContextTypes = function() {};',
+      'MyClass.DefaultProps = function() {};'
+    ].join('\n'),
+    parserOptions: parserOptions
+  }, {
+    code: [
+      'function MyRandomFunction() {}',
+      'MyRandomFunction.PropTypes = {};',
+      'MyRandomFunction.ContextTypes = {};',
+      'MyRandomFunction.ChildContextTypes = {};',
+      'MyRandomFunction.DefaultProps = {};'
+    ].join('\n'),
+    parserOptions: parserOptions
+  }, {
+    code: [
+      'class First extends React.Component {}',
+      'First["prop" + "Types"] = {};',
+      'First["context" + "Types"] = {};',
+      'First["childContext" + "Types"] = {};',
+      'First["default" + "Props"] = {};'
+    ].join('\n'),
+    parserOptions: parserOptions
+  }, {
+    code: [ // This case is currently not supported
+      'class First extends React.Component {}',
+      'First["PROP" + "TYPES"] = {};',
+      'First["CONTEXT" + "TYPES"] = {};',
+      'First["CHILDCONTEXT" + "TYPES"] = {};',
+      'First["DEFAULT" + "PROPS"] = {};'
+    ].join('\n'),
+    parserOptions: parserOptions
+  }, {
+    code: [ // This case is currently not supported
+      'const propTypes = "PROPTYPES"',
+      'const contextTypes = "CONTEXTTYPES"',
+      'const childContextTypes = "CHILDCONTEXTTYPES"',
+      'const defautProps = "DEFAULTPROPS"',
+      '',
+      'class First extends React.Component {}',
+      'First[propTypes] = {};',
+      'First[contextTypes] = {};',
+      'First[childContextTypes] = {};',
+      'First[defautProps] = {};'
+    ].join('\n'),
+    parserOptions: parserOptions
+  }],
+
+  invalid: [{
+    code: [
+      'class Component extends React.Component {',
+      '  static PropTypes = {};',
+      '}'
+    ].join('\n'),
+    parser: 'babel-eslint',
+    parserOptions: parserOptions,
+    errors: [{message: ERROR_MESSAGE}]
+  }, {
+    code: [
+      'class Component extends React.Component {}',
+      'Component.PropTypes = {}'
+    ].join('\n'),
+    parserOptions: parserOptions,
+    errors: [{message: ERROR_MESSAGE}]
+  }, {
+    code: [
+      'function MyComponent() { return (<div>{this.props.myProp}</div>) }',
+      'MyComponent.PropTypes = {}'
+    ].join('\n'),
+    parserOptions: parserOptions,
+    errors: [{message: ERROR_MESSAGE}]
+  }, {
+    code: [
+      'class Component extends React.Component {',
+      '  static proptypes = {};',
+      '}'
+    ].join('\n'),
+    parser: 'babel-eslint',
+    parserOptions: parserOptions,
+    errors: [{message: ERROR_MESSAGE}]
+  }, {
+    code: [
+      'class Component extends React.Component {}',
+      'Component.proptypes = {}'
+    ].join('\n'),
+    parserOptions: parserOptions,
+    errors: [{message: ERROR_MESSAGE}]
+  }, {
+    code: [
+      'function MyComponent() { return (<div>{this.props.myProp}</div>) }',
+      'MyComponent.proptypes = {}'
+    ].join('\n'),
+    parserOptions: parserOptions,
+    errors: [{message: ERROR_MESSAGE}]
+  }, {
+    code: [
+      'class Component extends React.Component {',
+      '  static ContextTypes = {};',
+      '}'
+    ].join('\n'),
+    parser: 'babel-eslint',
+    parserOptions: parserOptions,
+    errors: [{message: ERROR_MESSAGE}]
+  }, {
+    code: [
+      'class Component extends React.Component {}',
+      'Component.ContextTypes = {}'
+    ].join('\n'),
+    parserOptions: parserOptions,
+    errors: [{message: ERROR_MESSAGE}]
+  }, {
+    code: [
+      'function MyComponent() { return (<div>{this.props.myProp}</div>) }',
+      'MyComponent.ContextTypes = {}'
+    ].join('\n'),
+    parserOptions: parserOptions,
+    errors: [{message: ERROR_MESSAGE}]
+  }, {
+    code: [
+      'class Component extends React.Component {',
+      '  static contexttypes = {};',
+      '}'
+    ].join('\n'),
+    parser: 'babel-eslint',
+    parserOptions: parserOptions,
+    errors: [{message: ERROR_MESSAGE}]
+  }, {
+    code: [
+      'class Component extends React.Component {}',
+      'Component.contexttypes = {}'
+    ].join('\n'),
+    parserOptions: parserOptions,
+    errors: [{message: ERROR_MESSAGE}]
+  }, {
+    code: [
+      'function MyComponent() { return (<div>{this.props.myProp}</div>) }',
+      'MyComponent.contexttypes = {}'
+    ].join('\n'),
+    parserOptions: parserOptions,
+    errors: [{message: ERROR_MESSAGE}]
+  }, {
+    code: [
+      'class Component extends React.Component {',
+      '  static ChildContextTypes = {};',
+      '}'
+    ].join('\n'),
+    parser: 'babel-eslint',
+    parserOptions: parserOptions,
+    errors: [{message: ERROR_MESSAGE}]
+  }, {
+    code: [
+      'class Component extends React.Component {}',
+      'Component.ChildContextTypes = {}'
+    ].join('\n'),
+    parserOptions: parserOptions,
+    errors: [{message: ERROR_MESSAGE}]
+  }, {
+    code: [
+      'function MyComponent() { return (<div>{this.props.myProp}</div>) }',
+      'MyComponent.ChildContextTypes = {}'
+    ].join('\n'),
+    parserOptions: parserOptions,
+    errors: [{message: ERROR_MESSAGE}]
+  }, {
+    code: [
+      'class Component extends React.Component {',
+      '  static childcontexttypes = {};',
+      '}'
+    ].join('\n'),
+    parser: 'babel-eslint',
+    parserOptions: parserOptions,
+    errors: [{message: ERROR_MESSAGE}]
+  }, {
+    code: [
+      'class Component extends React.Component {}',
+      'Component.childcontexttypes = {}'
+    ].join('\n'),
+    parserOptions: parserOptions,
+    errors: [{message: ERROR_MESSAGE}]
+  }, {
+    code: [
+      'function MyComponent() { return (<div>{this.props.myProp}</div>) }',
+      'MyComponent.childcontexttypes = {}'
+    ].join('\n'),
+    parserOptions: parserOptions,
+    errors: [{message: ERROR_MESSAGE}]
+  }, {
+    code: [
+      'class Component extends React.Component {',
+      '  static DefaultProps = {};',
+      '}'
+    ].join('\n'),
+    parser: 'babel-eslint',
+    parserOptions: parserOptions,
+    errors: [{message: ERROR_MESSAGE}]
+  }, {
+    code: [
+      'class Component extends React.Component {}',
+      'Component.DefaultProps = {}'
+    ].join('\n'),
+    parserOptions: parserOptions,
+    errors: [{message: ERROR_MESSAGE}]
+  }, {
+    code: [
+      'function MyComponent() { return (<div>{this.props.myProp}</div>) }',
+      'MyComponent.DefaultProps = {}'
+    ].join('\n'),
+    parserOptions: parserOptions,
+    errors: [{message: ERROR_MESSAGE}]
+  }, {
+    code: [
+      'class Component extends React.Component {',
+      '  static defaultprops = {};',
+      '}'
+    ].join('\n'),
+    parser: 'babel-eslint',
+    parserOptions: parserOptions,
+    errors: [{message: ERROR_MESSAGE}]
+  }, {
+    code: [
+      'class Component extends React.Component {}',
+      'Component.defaultprops = {}'
+    ].join('\n'),
+    parserOptions: parserOptions,
+    errors: [{message: ERROR_MESSAGE}]
+  }, {
+    code: [
+      'function MyComponent() { return (<div>{this.props.myProp}</div>) }',
+      'MyComponent.defaultprops = {}'
+    ].join('\n'),
+    parserOptions: parserOptions,
+    errors: [{message: ERROR_MESSAGE}]
+  }]
+});