Need help with your JSON?

Try our JSON Formatter tool to automatically identify and fix syntax errors in your JSON. JSON Formatter tool

Creating Custom JSON Formatter Rules in ESLint

Maintaining consistent formatting and structure across all files in a project is crucial for readability and collaboration. While ESLint is primarily known for linting JavaScript and TypeScript, it can also be extended to enforce rules on other file types, including JSON.

Standard JSON formatters exist, but sometimes you need to enforce specific rules that go beyond simple indentation or spacing – rules about the presence of certain keys, the order of properties, or the format of values. This is where custom ESLint rules become invaluable.

Why Custom JSON Rules?

You might need custom JSON rules for various reasons:

  • Configuration Consistency: Enforce specific structures or required fields in configuration files (e.g., package.json, tsconfig.json, custom config files).
  • API Contract Validation: If you define API payloads or data structures in JSON, linting can help ensure they conform to expected formats.
  • Localization Files: Ensure translation files have all necessary keys and consistent structures.
  • Code Generation Input: Validate JSON files used as input for code generation processes.

ESLint Basics & JSON

ESLint works by parsing code into an Abstract Syntax Tree (AST) and then traversing this tree, allowing rules to visit specific node types (like function calls, variable declarations, object properties). For JavaScript, ESLint uses parsers like Esprima, Acorn, or@typescript-eslint/parser.

To lint file types other than standard JavaScript/TypeScript, ESLint relies on processors. A processor extracts code from a file (or transforms it) so ESLint can lint it. For JSON files, the most common approach is to use a plugin that includes a processor and defines rules specifically for JSON AST. The de facto standard for this is eslint-plugin-jsonc(or the older, less maintained eslint-plugin-json). We will focus on the concepts applicable when using such a plugin, specifically how to write a rule that understands a JSON AST.

eslint-plugin-jsonc provides a parser and processor that turns JSON text into an AST similar to ESTree (the standard AST for JS), but with node types relevant to JSON (e.g., JSONObjectExpression, JSONArrayExpression, JSONProperty, JSONIdentifier, JSONLiteral).

Structure of a Custom Rule

An ESLint rule is an object with two main parts:

  • meta: Provides metadata about the rule (type, description, fixability, schema for options).
  • create: A function that returns an object where keys are AST node types (or selectors) and values are functions that ESLint calls when traversing the AST and encountering a matching node.

For JSON rules using eslint-plugin-jsonc, you'll subscribe to node types like JSONObjectExpression, JSONProperty, JSONLiteral, etc.

Creating a Custom Rule for JSON

Let's outline the steps and provide examples.

1. Setup: Project Structure & Plugin

Custom rules are typically bundled in an ESLint plugin. For project-specific rules, you can create a local plugin directory.

Example Local Plugin Structure:

my-eslint-plugin/
├── index.js       <-- Defines the plugin
└── rules/
    └── my-json-rule.js <-- Your custom rule file

my-eslint-plugin/index.js:

module.exports = {
  rules: {
    "my-json-rule": require("./rules/my-json-rule"),
    // Add other rules here
  },
  // Processors can also be defined here if needed,
  // but for JSON, we rely on eslint-plugin-jsonc's processor.
};

2. Define the Rule File (`my-json-rule.js`)

This file will contain the rule definition object.

// my-eslint-plugin/rules/my-json-rule.js
module.exports = {
  meta: {
    type: "problem", // or "suggestion", "layout"
    docs: {
      description: "Enforce a custom rule for JSON objects.",
      category: "Possible Problems", // Or "Suggestions", "Layout & Formatting"
      recommended: false,
      url: "https://example.com/docs/my-json-rule", // Optional documentation URL
    },
    fixable: "code", // or "whitespace" or null
    schema: [], // Define options schema here
  },
  create: function (context) {
    // Rule logic goes here, returning visitor functions
    return {
      // Visitor function for a specific JSON AST node type
      // Example: checking the root object
      JSONObjectExpression(node) {
        // 'node' is the AST node for the top-level JSON object
        // 'context' provides methods like report()
        // console.log('Visited a JSON Object:', node);

        // Example check: Ensure the root object has a 'version' key
        const versionProperty = node.properties.find(prop =>
          prop.key.type === 'JSONIdentifier' && prop.key.name === 'version'
        );

        if (!versionProperty) {
          context.report({
            node: node, // Report on the object node
            message: "JSON object must contain a 'version' key.",
          });
        }

        // Example check: Ensure 'version' value is a string
        if (versionProperty && versionProperty.value.type !== 'JSONLiteral' && typeof versionProperty.value.value !== 'string') {
             context.report({
                node: versionProperty.value, // Report on the value node
                message: "'version' key must have a string value.",
             });
        }
      },

      // You can define more visitor functions for other node types
      // JSONProperty(node) {
      //   // 'node' is an AST node for a key-value pair within an object
      //   // console.log('Visited a JSON Property:', node.key.name);
      // },
      // JSONArrayExpression(node) {
      //   // 'node' is an AST node for an array
      // }
    };
  },
};

Key parts:

  • meta.type: How ESLint should treat issues from this rule.
  • meta.docs: Description and category.
  • meta.fixable: Indicates if ESLint can automatically fix the issue.
  • meta.schema: Defines options the rule accepts.
  • create(context): The factory function. contextprovides methods like context.report() to flag issues.
  • Visitor functions (e.g., JSONObjectExpression(node)): These are called during the AST traversal. The node argument is the AST node being visited.

3. Example: Enforcing Key Order

Let's create a rule that checks if properties in a JSON object are sorted alphabetically.

my-eslint-plugin/rules/sort-json-keys.js:

// my-eslint-plugin/rules/sort-json-keys.js
module.exports = {
  meta: {
    type: "layout",
    docs: {
      description: "Enforce alphabetical sorting of keys in JSON objects.",
      category: "Layout & Formatting",
      recommended: false,
    },
    fixable: "code", // This rule can potentially auto-fix
    schema: [], // Simple rule, no options needed
  },
  create: function (context) {
    return {
      // Visitor for JSON object nodes
      JSONObjectExpression(node) {
        const properties = node.properties;

        if (properties.length <= 1) {
          // No need to sort if 0 or 1 property
          return;
        }

        // Get the names of the keys as strings
        const keyNames = properties.map(prop => {
          // Handle different key types (Identifier or Literal)
          if (prop.key.type === 'JSONIdentifier' || (prop.key.type === 'JSONLiteral' && typeof prop.key.value === 'string')) {
            return prop.key.name || prop.key.value; // Use name for Identifier, value for string Literal
          }
          // For other literal types (numbers, booleans), treat as string
           if (prop.key.type === 'JSONLiteral') {
               return String(prop.key.value);
           }
          // If key is not a simple type we can compare (e.g., object key), ignore
          return null;
        }).filter(name => name !== null); // Filter out non-comparable keys

        // Create a sorted version of the key names
        const sortedKeyNames = [...keyNames].sort((a, b) => a.localeCompare(b));

        // Compare the original order with the sorted order
        for (let i = 0; i < keyNames.length; i++) {
          if (keyNames[i] !== sortedKeyNames[i]) {
            // Found a key that is out of order
            const currentKeyNode = properties[i].key;
            const expectedKeyName = sortedKeyNames[i];

            context.report({
              node: currentKeyNode, // Report on the key that is out of order
              message: "Keys must be sorted alphabetically. Expected '{{expected}}' before '{{found}}'.",
              data: {
                 expected: expectedKeyName,
                 found: keyNames[i],
              },
              // Optional: Add a fixer function for auto-fixing
              // This is more complex as it involves rearranging nodes.
              // A simple example fixer for a property:
              // fixer: function(fixer) {
              //   // This would require more sophisticated logic
              //   // to rearrange properties in the parent object.
              //   // Usually, for sorting, it's easier to report and let a formatter handle it
              //   // or implement a more complex multi-node fixer.
              //   return null; // No fix provided for this simple example
              // }
            });
            // Report only the first out-of-order key in this object
            break;
          }
        }
      },
    };
  },
};

This rule iterates through the properties of each object it encounters. It extracts the key names, sorts them, and then compares the original order to the sorted order. If a key is found out of place, it reports an error. Implementing the fixer function for sorting is more complex as it needs to know the source code range of multiple properties to rearrange them.

4. Example: Validating Specific Key Values

Let's create a rule that checks if the "version" key in package.json matches a specific pattern (e.g., semver).

my-eslint-plugin/rules/valid-package-version.js:

// my-eslint-plugin/rules/valid-package-version.js
// Requires eslint-plugin-jsonc to provide the JSON AST node types

// Simple regex for basic semver (major.minor.patch)
// Does NOT cover pre-release or build metadata
const SEMVER_REGEX = /^\d+\.\d+\.\d+$/;

module.exports = {
  meta: {
    type: "problem",
    docs: {
      description: "Enforce semver format for package.json version.",
      category: "Possible Problems",
      recommended: false,
    },
    fixable: null, // Cannot auto-fix version format
    schema: [],
  },
  create: function (context) {
    return {
      // Selects a JSONProperty node with a key named "version"
      // This is a more specific selector than just JSONProperty
      'JSONProperty[key.name="version"]'(node) {
        // 'node' is the AST node for the "version": "..." property
        const valueNode = node.value;

        // Check if the value is a string literal
        if (valueNode.type === 'JSONLiteral' && typeof valueNode.value === 'string') {
          const version = valueNode.value;

          // Check if the string matches the semver regex
          if (!SEMVER_REGEX.test(version)) {
            context.report({
              node: valueNode, // Report on the value node
              message: "Package version '{{version}}' must follow semver format (major.minor.patch).",
              data: {
                 version: version,
              },
            });
          }
        } else {
             // Report if the value is not a string literal
             context.report({
                node: valueNode, // Report on the value node
                message: "Package version must be a string literal.",
             });
        }
      },
    };
  },
};

This rule uses an AST selector string 'JSONProperty[key.name="version"]' to target only property nodes whose key is named "version". Inside the visitor function, it accesses the value node of the property and checks if it's a string literal matching a basic semver pattern.

5. Integrate into ESLint Configuration

Once you have your local plugin, you need to tell ESLint about it and configure the rules.

Example .eslintrc.js or .eslintrc.json:

// .eslintrc.js
const path = require('path');

module.exports = {
  // ... other ESLint configurations

  // Tell ESLint where to find your local plugin
  plugins: [
    "jsonc", // Add the jsonc plugin first
    require(path.resolve(__dirname, 'my-eslint-plugin')), // Path to your local plugin
  ],

  // Define overrides to apply rules specifically to JSON files
  overrides: [
    {
      files: ["*.json", "*.jsonc"], // Target JSON and JSONC files
      parser: "jsonc-eslint-parser", // Use the jsonc parser

      rules: {
        // Configure jsonc plugin's recommended rules (optional)
        // "jsonc/recommended-with-json": "warn",
        // "jsonc/recommended-with-jsonc": "warn",
        // "jsonc/auto": "warn", // Or "jsonc/auto" for automatically picking settings

        // Configure your custom rules
        "my-eslint-plugin/my-json-rule": "error", // Use the rule name from index.js
        "my-eslint-plugin/sort-json-keys": "warn",
        "my-eslint-plugin/valid-package-version": [
          "error", // Enable rule with severity "error"
          // You could pass options here if your rule schema defined them
          // { "pattern": "^v\d+\.\d+\.\d+$" }
        ],

        // You can also use other jsonc rules
        "jsonc/indent": ["error", 2], // Enforce 2-space indentation
        "jsonc/no-bigint-literals": "error", // Disallow BigInt (if needed)
        // etc.
      },
    },
    // Add other overrides if you have different rules for different JSON files
    // {
    //   files: ["package.json"],
    //   rules: {
    //     "my-eslint-plugin/valid-package-version": "error", // Apply specifically to package.json
    //     // Other package.json specific rules
    //   }
    // }
  ],

  // ... rest of your config
};

Important points for JSON linting:

  • Include "jsonc" in the plugins array.
  • Include your local plugin path in the plugins array.
  • Use the overrides section to specify rules that only apply to JSON files.
  • Inside the JSON override, set parser: "jsonc-eslint-parser".
  • Configure your custom rules using the format "plugin-name/rule-name".

6. Running ESLint

With the configuration set up, you can run ESLint from your terminal:

# Lint specific JSON files
npx eslint path/to/your/file.json

# Lint all JSON files matching a pattern
npx eslint "**/*.json"

# Lint all files in the project
npx eslint .

ESLint will use the overrides to apply the JSON parser and your custom rules to the specified files.

Advanced Considerations

  • Fixers: Implementing auto-fixing (`meta.fixable`) requires careful use of the fixer object provided in the context.report function. For simple changes (like adding a missing key), it's straightforward. For complex changes (like sorting), it's much harder and often relies on replacing larger sections of code or even the entire file content based on the desired structure.
  • Options: Use meta.schema to define options for your rule, allowing users to customize its behavior (e.g., specifying the required keys or the sorting order). Access options via context.options.
  • Testing: Thoroughly test your rules using ESLint'sRuleTester. This involves providing valid and invalid code examples and asserting that the rule reports errors correctly (and fixes them if applicable).
  • JSON with Comments/Trailing Commas (JSONC): eslint-plugin-jsoncalso handles JSONC format. If your files allow comments, make sure your rules are robust enough to handle them or only apply the rules to standard JSON files.

Conclusion

While the standard JSON specification is quite simple, the JSON files used in real-world projects often have specific structural or content requirements. By leveraging ESLint's plugin architecture and a processor like eslint-plugin-jsonc, you can create powerful custom rules to enforce these project-specific conventions. This ensures greater consistency, reduces errors, and improves the maintainability of your configuration and data files, just like ESLint does for your code.

Need help with your JSON?

Try our JSON Formatter tool to automatically identify and fix syntax errors in your JSON. JSON Formatter tool