An ESLint rule for project structure

By Baptiste Devessier

At Twenty, we rely on code conventions to keep the codebase coherent. That’s particularly important since we are an open-source project and receive many contributions.

We have different conventions for the frontend and the backend code. Our conventions are about the coding style, the folders’ architecture, the files’ naming, etc.

We rely on automatic tools to help us enforce our conventions. We use TypeScript, Prettier, and ESLint and run them for every Pull Request in our CI.

Recently, we introduced a new ESLint plugin to enforce file and folder structure, eslint-plugin-project-structure. If one of your files or directories doesn’t match the allowlist you configure, you will see an error like this:

The file “build-IndexTablePageURL” doesn’t match the camel case pattern
The file “build-IndexTablePageURL” doesn’t match the camel case pattern

Igor Kowalski, the creator of the ESLint plugin, came to help us use the library best. This is another benefit of being an open-source project!

Let’s dive into the rules we chose to apply.

Enable the project-structure plugin

Currently, we only enabled the project-structure plugin for the frontend. We might likely use it in the backend in the future, but we wanted to experiment with it on the frontend first.

The project-structure plugin is enabled for the frontend in the ESLint configuration:

// packages/twenty-front/.eslintrc.cjs

module.exports = {
  // ...
  overrides: [
    {
      files: ['*.ts', '*.tsx'],
      parserOptions: {
        project: ['packages/twenty-front/tsconfig.{json,*.json}'],
      },
      plugins: ['project-structure'],
      settings: {
        'project-structure/folder-structure-config-path': path.resolve(
          __dirname,
          'folderStructure.json'
        ),
      },
      rules: {
        'project-structure/folder-structure': 'error',
      },
    },
  ],
};

We only want to run the project-structure plugin on TypeScript and TSX files. We use an override for these files:

module.exports = {
  // ...
  overrides: [
    {
      files: ['*.ts', '*.tsx'],
      // ...
    },
  ],
};

We set the parser that ESLint must use for these files:

module.exports = {
  // ...
  overrides: [
    {
      files: ['*.ts', '*.tsx'],
      parserOptions: {
        project: ['packages/twenty-front/tsconfig.{json,*.json}'],
      },
    },
  ],
};

Finally, we activate the project-structure plugin specifically for these files. The project-structure plugin must be defined in a separate JSON file, and we must provide the path to this file.

We also want to consider the errors thrown by the ESLint plugin critical. Contributors will have to fix them before we merge their Pull Requests.

module.exports = {
  // ...
  overrides: [
    {
      files: ['*.ts', '*.tsx'],
      parserOptions: {
        project: ['packages/twenty-front/tsconfig.{json,*.json}'],
      },
      plugins: ['project-structure'],
      settings: {
        'project-structure/folder-structure-config-path': path.resolve(
          __dirname,
          'folderStructure.json'
        ),
      },
      rules: {
        'project-structure/folder-structure': 'error',
      },
    },
  ],
};

Define our rules

We defined our rules in the packages/twenty-front/folderStructure.json file:

{
  "$schema": "../../node_modules/eslint-plugin-project-structure/folderStructure.schema.json",
  "projectRoot": "packages/twenty-front",
  "structureRoot": "src",
  "regexParameters": {
    "camelCase": "^[a-z]+([A-Za-z0-9]+)+",
    "kebab-case": "[a-z][a-z0-9]*(?:-[a-z0-9]+)*"
  },
  "structure": [
    { "name": "*" },
    { "name": "*", "children": [] },
    { "name": "modules", "ruleId": "modulesFolderRule" }
  ],
  "rules": {
    "modulesFolderRule": {
      "children": [
        { "ruleId": "moduleFolderRule" },
        { "name": "types", "children": [] }
      ]
    },

    "moduleFolderRule": {
      "name": "{kebab-case}",
      "folderRecursionLimit": 6,
      "children": [
        { "ruleId": "moduleFolderRule" },
        { "name": "hooks", "ruleId": "hooksLeafFolderRule" },
        { "name": "utils", "ruleId": "utilsLeafFolderRule" },
        { "name": "states", "children": [] },
        { "name": "types", "children": [] },
        { "name": "graphql", "children": [] },
        { "name": "components", "children": [] },
        { "name": "effect-components", "children": [] },
        { "name": "constants", "children": [] },
        { "name": "validation-schemas", "children": [] },
        { "name": "contexts", "children": [] },
        { "name": "scopes", "children": [] },
        { "name": "services", "children": [] },
        { "name": "errors", "children": [] }
      ]
    },

    "hooksLeafFolderRule": {
      "folderRecursionLimit": 2,
      "children": [
        { "name": "use{PascalCase}.(ts|tsx)" },
        {
          "name": "__tests__",
          "children": [{ "name": "use{PascalCase}.test.(ts|tsx)" }]
        },
        { "name": "internal", "ruleId": "hooksLeafFolderRule" }
      ]
    },

    "utilsLeafFolderRule": {
      "children": [
        { "name": "{camelCase}.ts" },
        {
          "name": "__tests__",
          "children": [{ "name": "{camelCase}.test.ts" }]
        }
      ]
    }
  }
}

Warning

This configuration is an allowlist. The project-structure plugin will err if a file or a directory doesn’t match the configuration.

If you want to force the existence of a file or a directory, check the enforceExistence property.

Let’s break down this configuration. It’s easier than it seems at first.

Define the global structure

Firstly, we tell the plugin where it can run:

{
  "projectRoot": "packages/twenty-front",
  "structureRoot": "src",
}

Then, we define the structure the project must follow:

{
  // ...
  "structure": [
    { "name": "*" },
    { "name": "*", "children": [] },
    { "name": "modules", "ruleId": "modulesFolderRule" }
  ],
}

In order of declaration, this structure array means:

  • Any file is allowed at the top level and won’t be further checked.
  • Any directory is allowed at the top level and won’t be checked further.
  • A modules directory is expected at the top level and must follow the rules referenced by their id: modulesFolderRule.

Define the rules for the modules directory

Below the structure declaration, we can find the rules declaration. The first rule the plugin processes is modulesFolderRule, which the modules top-level directory must fulfill.

{
  // ...
  "structure": [
    // ...
    { "name": "modules", "ruleId": "modulesFolderRule" }
  ],
  "rules": {
    "modulesFolderRule": {
      "children": [
        { "ruleId": "moduleFolderRule" },
        { "name": "types", "children": [] }
      ]
    },
    // ...
  }
}

The modulesFolderRule ensures:

  • A types directory is allowed and can contain any content.
  • Any other file or directory must fulfill the moduleFolderRule. Note that this rule is for a single module, while the parent rule is for all the modules.

Define the rules for a module’s content

This is where things get interesting. Here, we set the allowed files and directories in one module.

At Twenty, we structure code in modules. Contrary to the conventions in major frontend frameworks like Next.js, where you have root components and pages directories, we chose to split the codebase by concern and not by functionality.

We define each module’s components, hooks, and utils independently. It means modules/workflows/components and modules/attachments/components exist.

Here are the rules for each module:

{
  "regexParameters": {
    "kebab-case": "[a-z][a-z0-9]*(?:-[a-z0-9]+)*"
  },
  // ...
  "rules": {
    "modulesFolderRule": {
      "children": [
        { "ruleId": "moduleFolderRule" },
        { "name": "types", "children": [] }
      ]
    },

    "moduleFolderRule": {
      "name": "{kebab-case}",
      "folderRecursionLimit": 6,
      "children": [
        { "ruleId": "moduleFolderRule" },
        { "name": "hooks", "ruleId": "hooksLeafFolderRule" },
        { "name": "utils", "ruleId": "utilsLeafFolderRule" },
        { "name": "states", "children": [] },
        { "name": "types", "children": [] },
        { "name": "graphql", "children": [] },
        { "name": "components", "children": [] },
        { "name": "effect-components", "children": [] },
        { "name": "constants", "children": [] },
        { "name": "validation-schemas", "children": [] },
        { "name": "contexts", "children": [] },
        { "name": "scopes", "children": [] },
        { "name": "services", "children": [] },
        { "name": "errors", "children": [] }
      ]
    },

    // ...
  }
}

First, the moduleFolderRule uses a regex parameter to validate the entry’s name. We can define custom regex and write one to ensure the name of the entry follows the kebab-case.

Then, we set the allowed folders. Setting "children": [] checks that the entry is a directory but doesn’t check its content. The allowed directories are components, hooks, utils, states, etc.

We define more specific rules for the hooks and utils directories. We’ll talk about them in a moment.

The last interesting configuration here is about sub-modules. Indeed, we allow modules to be nested. One great example is the object-record module, which contains many sub-modules, such as record-board or record-table.

The project-structure plugin makes recursion possible like so:

{
  "rules": {
    "moduleFolderRule": {
      // ...
      "folderRecursionLimit": 6,
      "children": [
        { "ruleId": "moduleFolderRule" },
        // ...
      ]
    },
  }
}

Every entry that didn’t match the rules checking for specific folders definition will be validated against the moduleFolderRule. The entry must be a directory with the components, hooks, utils, etc. folders.

Recursivity is great, but if you want to prevent infinite loops, you must set an end case. Here, we set the recursion limit to 6. This prevents people from creating modules that are too deeply nested, which might indicate poorly designed code.

{
  "rules": {
    "moduleFolderRule": {
      "folderRecursionLimit": 6,
    },
  }
}

Define the specific rules for the hooks directory

The team has opinions on the structure of the hooks directories.

{
  "rules": {
    "moduleFolderRule": {
      "children": [
        { "name": "hooks", "ruleId": "hooksLeafFolderRule" },
      ]
    },

    "hooksLeafFolderRule": {
      "folderRecursionLimit": 2,
      "children": [
        { "name": "use{PascalCase}.(ts|tsx)" },
        {
          "name": "__tests__",
          "children": [{ "name": "use{PascalCase}.test.(ts|tsx)" }]
        },
        { "name": "internal", "ruleId": "hooksLeafFolderRule" }
      ]
    },
  }
}

We want every file containing a hook to be prefixed with use, like useSelectAllRows, and to use pascal case. Hook files can end with ts or tsx, depending on whether they must use JSX.

A __tests__ directory is also authorized in hooks directories. The files’ name must follow the same pattern as the hook files themselves, except a .test. string before the extension.

Finally, hooks directories can put internal hooks in an internal directory. We use recursion here to enforce the structure of the internal folders strictly.

Define the specific rules for the utils directory

We put utility functions used across several components or files in a utils directory.

{
  "rules": {
    "moduleFolderRule": {
      "children": [
        { "name": "utils", "ruleId": "utilsLeafFolderRule" },
      ]
    },

    "utilsLeafFolderRule": {
      "children": [
        { "name": "{camelCase}.ts" },
        {
          "name": "__tests__",
          "children": [{ "name": "{camelCase}.test.ts" }]
        }
      ]
    }
  }
}

The utils folders can only contain TypeScript files named following the camel case. These folders can also contain a __tests__ folder whose files must follow the naming of the utility files.

The benefit of relying on a JSON Schema

You might have noticed the $schema property at the beginning of the folderStructure.json file:

{
  "$schema": "../../node_modules/eslint-plugin-project-structure/folderStructure.schema.json",
}

This refers to a JSON file defined in the eslint-plugin-project-structure package. You can find an online version of this file. The plugin’s configuration is written in a JSON file and relies on a JSON Schema to allow code editors validation and autocompletion.

JSON Schema is a format to define validation rules. You can specify length requirements for strings, the optionality of fields, or the options of an enumeration.

Here are a few examples taken from the JSON Schema of eslint-plugin-project-structure:

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "definitions": {
    "Rule": {
      "type": "object",
      "default": { "name": "" },
      "additionalProperties": false,
      "properties": {
        "name": {
          "type": "string",
          "default": ""
        },
        "children": {
          "type": "array",
          "default": [],
          "items": {
            "$ref": "#/definitions/Rule"
          }
        }
      }
    }
  },
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "structure": {
      "oneOf": [
        {
          "$ref": "#/definitions/Rule"
        },
        {
          "type": "array",
          "default": [],
          "items": {
            "$ref": "#/definitions/Rule"
          }
        }
      ]
    },
  },
  "required": ["structure"]
}

Most code editors and IDEs will understand JSON Schemas and autocomplete properties for you.

VSCode autocompletes children and enforceExistence properties for children of the structure array in the configuration
VSCode autocompletes children and enforceExistence properties for children of the structure array in the configuration

I find this to be a smart way to make plugins’ configuration safe.

More strictness in the future?

We only implemented one rule of the eslint-plugin-project-structure, which also exports the independent‑modules and file‑composition plugins.

We might decide to enforce even more things in the project. We don’t use the folder-structure yet in the backend; that might be an improvement.