Stack logo
Sync up on the latest from Convex.
Ian Macartney's avatar
Ian Macartney
9 months ago

Set up ESLint for best practices

Setup ESLint to enforce type checking and Typescript code quality

ESLint is a linter for JavaScript / TypeScript that goes beyond type checking to enforce best practices. For instance, ensuring you don’t have unused parameters (which may indicate a bug where you shadowed a variable or don’t await a promise. The tool is powerful and allows extensions and plugins, such as React-specific rules.

This post looks at configuring ESLint for best practices for serverless functions, Convex in particular.

Configuring ESLint

You can start from scratch with npm init @eslint/config and hand-craft your own file, or use an opinionated collection of best practices from a package like Airbnb’s eslint-config-airbnb.

See below for a configuration that includes my preferred defaults, including Convex best practices.

You can add a package.json script to allow you to do npm run lint, like:

"scripts": {
    "lint": "eslint . --ext .ts,.tsx",

Awaiting all promises in server functions

If you are running code on the server and you start an async function, it’s best practice to await its result before returning a result to the client. Having code run after a function returns is best pushed into a function that’s explicitly run asynchronously, like Convex’s scheduled functions.

In Convex, all database operations like db.get, db.query, db.delete etc. are async functions. In addition, operations in an action, like ctx.scheduler.* need to be awaited. Otherwise it may or may not actually run, depending when your function returns. Many serverless providers have this gotcha. Convex will print a helpful error message to your logs when you do this, but ESLint can help you catch it earlier. Just add this rule in the rules section:

"@typescript-eslint/no-floating-promises": "error",

This requires "@typescript-eslint" listed in the config’s "plugins", which comes from the @typescript-eslint/eslint-plugin@latest npm package.

Enforcing imports with ESLint

When you want to extend the behavior of functions that come from an npm package or codegen, one way of building abstractions is to wrap the function call with a utility function. This way you can add behavior onto the original function. For instance, if you’re using a library like Ents with Convex, you want developers to interact with the database through the Ent table functions instead of ctx.db directly, as they can have side-effects like cascading deletes. Read more about Ent custom function wrapping here.

Another common use case is for the custom functions pattern. Custom functions let you define a new version of a server function, like query, mutation, or action that do some common work before a request. This pattern is opt-in, so if you’re doing special authorization checks for a custom query, another developer can still import the “raw” query function from the convex/_generated/server codegen file. To enforce the usage of your custom function, you can use an ESLint rule: no-restricted-imports.

no-restricted-imports

To disallow importing the wrapped functions via ESLint, use no-restricted-imports to define this rule in the rules section:

"no-restricted-imports": [
  "error",
  {
    patterns: [
      {
        group: ["*/_generated/server"],
        importNames: ["query", "mutation", "action"],
        message: "Use functions.ts for query, mutation, or action",
      },
    ],
  },
],

This throws an error whenever someone imports query, mutation, or action from anything ending in _generated/server. Instead, they are encouraged to use the functions.ts version of them.

Note: you may also want to include internalQuery, internalMutation, and internalAction to also discourage importing these raw functions. Inner here means they can only be called from other server functions. For the above example, I only care about enforcing usage of custom functions for client-facing APIs.

convex/functions.ts

In convex/functions.ts, I define custom functions that ensure all queries, mutations, and actions are only run by users from my company, by looking at the verfied email’s domain:

/* eslint-disable no-restricted-imports */
import {
  action as actionRaw,
  mutation as mutationRaw,
  query as queryRaw,
} from "./_generated/server";
/* eslint-enable no-restricted-imports */
import { ConvexError } from "convex/values";
import { customAction, customCtx, customMutation, customQuery } from "convex-helpers/server/customFunctions";

const authCheck = customCtx(async (ctx) => {
  const identity = await ctx.auth.getUserIdentity();
  if (!identity) throw new ConvexError("Not authenticated");
  if (!identity.emailVerified) throw new ConvexError("Email not verified");
  if (!identity.email?.endsWith("@convex.dev"))
    throw new ConvexError("Convex emails only");
  return {};
});

export const query = customQuery(queryRaw, authCheck);
export const mutation = customMutation(mutationRaw, authCheck);
export const action = customAction(actionRaw, authCheck);

This exports new query, mutation, and action functions that behave like the ones imported from ./_generated/server, with an auth check before they run. Now, other files can import them like import { query, mutation } from "./functions"; and use them in place of the originals.

Escape hatch

Note that we’re disabling the no-restricted-imports rule during the import:

/* eslint-disable no-restricted-imports */
import {
  action as actionRaw,
  mutation as mutationRaw,
  query as queryRaw,
} from "./_generated/server";
/* eslint-enable no-restricted-imports */

We want to limit that import everywhere except this file where we’re wrapping them. This is also how you can get around the rule if you want to use the raw functions somewhere, though I’d encourage you to instead re-export them from functions.ts like:

export {
  queryRaw as publicQuery,
  mutationRaw as publicMutation,
  actionRaw as publicAction,
};

Then you explicitly use them as publicQuery where you want to, and don’t need to make exceptions to ESLint rules more than absolutely necessary.

Full .eslintrc.cjs

This is the configuration I’m using in a React / Vite project to make a CMS on top of Convex:

module.exports = {
  env: {
    browser: true,
    es2021: true,
    node: true,
  },
  extends: [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:react/recommended",
  ],
  ignorePatterns: [".eslintrc.cjs", "convex/_generated", "node_modules"],
  overrides: [
    {
      env: {
        node: true,
      },
      files: [".eslintrc.{js,cjs}"],
      parserOptions: {
        sourceType: "script",
      },
    },
  ],
  parser: "@typescript-eslint/parser",
  parserOptions: {
    ecmaVersion: "latest",
    project: true,
    sourceType: "module",
  },
  plugins: ["@typescript-eslint", "react"],
  rules: {
    // Only warn on unused variables, and ignore variables starting with `_`
    "@typescript-eslint/no-unused-vars": [
      "warn",
      { varsIgnorePattern: "^_", argsIgnorePattern: "^_" },
    ],

    // Await your promises
    "@typescript-eslint/no-floating-promises": "error",

    // Allow explicit `any`s
    "@typescript-eslint/no-explicit-any": "off",

    "react/react-in-jsx-scope": "off",
    "react/prop-types": "off",

    // http://eslint.org/docs/rules/no-restricted-imports
    "no-restricted-imports": [
      "error",
      {
        patterns: [
          {
            group: ["*/_generated/server"],
            importNames: ["query", "mutation", "action"],
            message: "Use functions.ts for query, mutation, action",
          },
        ],
      },
    ],
  },
  settings: {
    react: {
      version: "detect",
    },
  },
};

To use this, you’ll need to install these packages:

npm i -D eslint@latest @typescript-eslint/parser@latest @typescript-eslint/eslint-plugin@latest eslint-plugin-react@latest 

"plugins", "extends", and configurations

For those curious, the difference between “plugins” and “extends” in the config is that a plugin doesn’t add any rules by default. It includes rules you can explicitly use, as well as rule configurations you can add in the “extends” section which adds many rules at once. For instance, plugin:react/recommended in the extends section comes from the react plugin in the plugins list, which comes from the eslint-plugin-react@latest npm package. Some packages don’t provide a plugin and only provide a configuration to use in “extends” and are named as such, like eslint-config-airbnb.

Summary

We looked at adding ESLint to your project and special rules to enforce best practices in Convex. This is just one of the ways to make your app more secure. To see other best practices, check out other Patterns posts.

Build in minutes, scale forever.

Convex is the sync platform with everything you need to build your full-stack project. Cloud functions, a database, file storage, scheduling, search, and realtime updates fit together seamlessly.

Get started