ESLint’s no-restricted-syntax rule

by Brian Simon ()

ESLint’s no-restricted-syntax rule bans specific JavaScript syntax patterns by matching abstract syntax tree (AST) nodes with CSS-like ESQuery selectors. It can enforce many project-specific conventions without a custom plugin.

Understanding the rule

ESLint parses JavaScript into an AST, where each node represents a syntax element. no-restricted-syntax matches ESQuery selectors against that tree.

Parsers, ESTree, and ESQuery

Three pieces make the rule work:

Parsers such as Espree, ESLint’s default parser, convert JavaScript source into an AST. This lets ESLint inspect code structure instead of plain text.

ESTree defines common JavaScript AST node types, including CallExpression, FunctionDeclaration, and IfStatement, along with their properties and relationships. Tools that follow ESTree can share the same representation.

ESQuery applies CSS-style selectors to ESTree ASTs. Where CSS can select div.className > span[data-id], ESQuery can select CallExpression[callee.name='eval'].

Why ASTs beat plain text

ASTs discard irrelevant formatting, so selectors match structure rather than characters. CallExpression[callee.name='eval'] catches all of these forms:

eval(code)
eval  (  code  )
(eval)(code)
eval(
  code
)

A text-based pattern would need to handle each formatting variation.

A lightweight alternative to custom ESLint plugins

For team conventions, experimental rules, and one-off legacy restrictions, a selector can replace a plugin scaffold, build step, and published package.

Combining with bulk suppressions for legacy code

In a large codebase, you might need to stop new uses of a deprecated pattern without fixing every existing violation. Rushstack’s ESLint bulk suppressions pair well with no-restricted-syntax.

Ban the pattern with no-restricted-syntax, then suppress existing violations:

  1. Bulk suppression files track existing violations.
  2. ESLint catches new violations during development.
  3. Refactoring removes suppressions over time.

Basic syntax

The rule accepts an array of objects, each specifying a selector and message:

{
  "rules": {
    "no-restricted-syntax": [
      "error",
      {
        "selector": "ForInStatement",
        "message": "for..in loops iterate over the entire prototype chain, which is virtually never what you want. Use Object.{keys,values,entries}, and iterate over the resulting array."
      }
    ]
  }
}

ESQuery selector types

ESQuery selectors resemble CSS selectors but operate on AST nodes.

The simplest kind names a node type and nothing else:

{
  "selector": "DebuggerStatement",
  "message": "Debugger statements should not be left in production code"
}
{
  "selector": "WithStatement",
  "message": "with statements are deprecated and should not be used"
}

Attribute selectors filter by a property on the node, using dotted paths to reach into nested properties:

{
  "selector": "CallExpression[callee.name='eval']",
  "message": "eval() is dangerous and should be avoided"
}

The child combinator (>) walks the tree the same way it does in CSS. The selector below fires only on an assignment sitting directly inside a for-in body:

{
  "selector": "ForInStatement > BlockStatement > ExpressionStatement > AssignmentExpression",
  "message": "Avoid direct assignment in for-in loops"
}

Pseudo-selectors such as :has() and :not() express more complex conditions:

{
  "selector": "CallExpression:has(MemberExpression[property.name='forEach']) > ArrowFunctionExpression[async=true]",
  "message": "Avoid async callbacks in forEach - use for...of or Promise.all instead"
}

Practical examples

These are rules I use in production.

Detecting deprecated Vue components

Catch imports of deprecated components to prevent their use in new code:

{
  "selector": "ImportSpecifier[imported.name=/^(OldButton|OldCard|OldDialog)$/]",
  "message": "This component is deprecated. Use the new design system components instead: Button, Card, Dialog"
}

Enforcing error logging in catch blocks

Require every catch block to log errors to your monitoring system:

{
  "selector": "CatchClause:not(:has(ThrowExpression)):not(:has(CallExpression[callee.object.object.name='window'][callee.object.property.name='DD_RUM'][CallExpression[callee.property.property.name='addError']))",
  "message": "Catch blocks must re-throw errors or log them to Datadog with window.DD_RUM.addError()"
}

This prevents silent failures when UI code catches an error to show a notification but forgets to report it to monitoring.

Enforcing async and await best practices

Catch the common mistake of using async callbacks in array methods:

{
  "selector": "CallExpression[callee.property.name='forEach'] > ArrowFunctionExpression[async=true]",
  "message": "Async callbacks in forEach don't work as expected. Use for...of loop or Promise.all() instead"
}

Vue.js and single file components

Vue-specific restrictions with eslint-plugin-vue

Vue.js applications can use a specialized version of this rule from eslint-plugin-vue. The vue/no-restricted-syntax rule extends the core ESLint rule to work with Vue’s single file components (SFCs), since the default parser doesn’t understand Vue’s template syntax.

Install eslint-plugin-vue, then configure the rule in your ESLint config:

{
  "rules": {
    "vue/no-restricted-syntax": [
      "error",
      {
        "selector": "VElement[name='div'] > VElement[name='div'] > VElement[name='div']",
        "message": "Avoid deeply nested div elements in templates"
      }
    ]
  }
}

Vue AST node types

Vue’s parser (vue-eslint-parser) introduces Vue-specific AST node types that you can target:

Template nodes

  • VElement: Vue template elements, such as <div> and <component>
  • VAttribute: Template attributes, such as class="foo" and :prop="value"
  • VDirective: Vue directives, such as v-if, v-for, and v-model
  • VDirectiveKey: The directive name portion, such as v-if and @click
  • VExpressionContainer: JavaScript expressions in templates
  • VText: Plain text content in templates
  • VStartTag and VEndTag: Opening and closing tags

Script and style nodes

  • VScriptElement: The <script> block
  • VStyleElement: The <style> block

Practical Vue examples

Prevent deprecated component usage in templates

{
  "selector": "VElement[name=/^(v-btn|v-card|v-dialog|q-btn|q-card)$/]",
  "message": "This component is deprecated. Use the new design system components: Button, Card, Dialog"
}

When to use no-restricted-syntax instead of an ESLint plugin

Choose based on the behavior your rule needs.

Use no-restricted-syntax to ban something

If you need to identify and ban specific syntax patterns, no-restricted-syntax is a good fit:

{
  "selector": "CallExpression[callee.name='eval']",
  "message": "eval() is dangerous and should be avoided"
}

Use the same approach for rules specific to one codebase:

{
  "selector": "ImportSpecifier[imported.name=/^(LegacyButton)$/]",
  "message": "Use NewButton from our design system instead"
}

I use rules like this during migrations because they’re temporary and don’t justify a plugin. Selectors are also a quick way to test whether a new rule catches useful problems.

Write an ESLint plugin when you need more power

Once you need to track state across multiple nodes, you’ve outgrown selectors:

// This kind of logic just isn't possible with a simple selector
module.exports = {
  create(context) {
    const eventListeners = [];
    const cleanupMethods = [];

    return {
      CallExpression(node) {
        if (isAddEventListener(node)) {
          eventListeners.push(node);
        }
        if (isRemoveEventListener(node)) {
          cleanupMethods.push(node);
        }
      },
      "Program:exit"() {
        // Match up listeners with their cleanup
        eventListeners.forEach(listener => {
          if (!hasMatchingCleanup(listener, cleanupMethods)) {
            context.report({
              node: listener,
              message: "Event listener needs cleanup"
            });
          }
        });
      }
    };
  }
};

Write a plugin when you need automatic fixes. no-restricted-syntax can report a problem but can’t change the code:

module.exports = {
  meta: {
    fixable: "code"
  },
  create(context) {
    return {
      CallExpression(node) {
        if (isDeprecatedFunction(node)) {
          context.report({
            node,
            message: "Use newFunction instead",
            fix(fixer) {
              return fixer.replaceText(node.callee, "newFunction");
            }
          });
        }
      }
    };
  }
};

If other teams would benefit from the rule, a plugin also makes it reusable:

// These kinds of rules often end up being useful across projects
module.exports = {
  rules: {
    "no-async-in-useeffect": require("./rules/no-async-in-useeffect"),
    "require-dependency-array": require("./rules/require-dependency-array"),
  }
};

You also need a plugin for type-aware rules. Selectors match syntax, which can’t tell you what a value’s type is. Use typescript-eslint for that:

module.exports = {
  meta: {
    docs: {
      requiresTypeChecking: true
    }
  },
  create(context) {
    const services = ESLintUtils.getParserServices(context);
    const checker = services.program.getTypeChecker();

    return {
      CallExpression(node) {
        const type = checker.getTypeAtLocation(services.esTreeNodeToTSNodeMap.get(node.callee));
        // Now you can make decisions based on actual types, not just syntax
      }
    };
  }
};

Start with a selector

I start with no-restricted-syntax to test the rule, then move to a plugin if it needs state, automatic fixes, type information, or reuse across projects.

The ESQuery selector you write for no-restricted-syntax can often be reused as the foundation for a custom plugin rule, so none of the work is wasted.