Insights

Using Invariants in Next.js Serverless Functions to Improve Code Quality and Identify Bugs Faster

Think assert functions but better

What is an Invariant and How is It Different From Assert?

The two are not all that different in that they server similar purposes, there are however several differences when it comes to how they are used in Next.js.

Error Handling

The use of an invariant is typically to throw an error that was intended to be caught and handled by, say, a serverless function. An assert on the other hand will typically terminate the program upon a failure of said assert.

Performance

In general the expectation is that invariants are something used as part of development to improve the speed of your coding by providing you more valuable feedback. As such, they are typically removed before a production build. There is a way however, we can keep them in our code as I’ll explain later.

Assertions, if remained in your code, can negatively affect performance.

Message Formatting

Due to their usage, assertions are typically very straight-forward in their messaging while invariants, which are integrated into the Next.js framework, can have a much more sophisticated response and may have special handing and formatting in error displays. Let me provide some examples of what I mean.

import { invariant } from 'next/dist/server/future/route-modules/app-route/module';

function validateUserData(userData) {
  const { id, name, email, age, roles } = userData;

  invariant(
    typeof id === 'string' && id.length === 36,
    `Invalid user ID: expected a 36-character string UUID, but got ${typeof id === 'string' ? `a ${id.length}-character string` : `a ${typeof id}`}. 
    Please ensure you're passing a valid UUID.`
  );

  invariant(
    typeof name === 'string' && name.length >= 2 && name.length <= 50,
    `Invalid name: "${name}". Name must be a string between 2 and 50 characters long. 
    Current length: ${name.length}`
  );

  invariant(
    typeof email === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email),
    `Invalid email address: "${email}". 
    Please provide a valid email address in the format user@example.com`
  );

  invariant(
    typeof age === 'number' && age >= 18 && age <= 120,
    `Invalid age: ${age}. Age must be a number between 18 and 120. 
    If you're trying to register a minor or someone over 120, please contact support.`
  );

  invariant(
    Array.isArray(roles) && roles.length > 0 && roles.every(role => typeof role === 'string'),
    `Invalid roles: ${JSON.stringify(roles)}. 
    Roles must be a non-empty array of strings. 
    Example of valid roles: ["user", "admin"]`
  );

  console.log("User data is valid!");
}

// Example data to test invariants
try {
  validateUserData({
    id: '123', // Invalid ID
    name: 'A', // Invalid name
    email: 'invalid-email', // Invalid email
    age: 15, // Invalid age
    roles: ['user', 42] // Invalid roles
  });
} catch (error) {
  console.error("Validation Error:", error.message);
}

Now the above is perhaps far an away much more sophisticated than you might need, but it gives you a good example of what’s possible.

Usage Context

Invariants in Next.js are often used for checking configuration and API usage. I’ve become especially fond of using them in serverless functions. Assertions are more commonly used for logic and testing.

So when we think of the challenges we have of validating and testing within a serverless function, extracting the error message from that can often be a challenge. With invariants however, it’s a lot simpler. Have a look at the following example.

import { invariant } from 'next/dist/server/future/route-modules/app-route/module';

export default function handler(req, res) {
  try {
    // Some operation that might trigger an invariant
    invariant(req.query.id, 'ID is required');

    // Rest of the handler logic...
    res.status(200).json({ success: true });
  } catch (error) {
    console.error('API Error:', error);

    if (process.env.NODE_ENV === 'development') {
      // In development, send the full error message
      res.status(400).json({ error: error.message });
    } else {
      // In production, send a generic message
      res.status(400).json({ error: 'An error occurred. Please try again.' });
    }
  }
}

Let’s walk through the above code for when the invariant throws an error “ID is required”.

  1. The invariant throws an error with the message "ID is required".
  2. This error is caught in the try/catch block.
  3. In the catch block, we have this condition:

     if (process.env.NODE_ENV === 'development') {
     // In development, send the full error message
       res.status(400).json({ error: error.message });
     }
    
  4. Since we're in a development environment, this condition is true.

  5. The full error message ("ID is required") is sent back in the JSON response with a 400 status code.

So, if you were to make a request to this API endpoint in a development environment without providing an ID in the query parameters, you would receive a response like this:

{
  "error": "ID is required"
}

This message would be visible in several places:

  • In the Network tab of your browser's Developer Tools, you'd see this response body.
  • If you're using a tool like Postman to test your API, you'd see this message in the response body.
  • In your server logs or terminal where you're running your Next.js development server, you'd likely see a log of this error. Or if you have Docker Desktop running you’d see it in the rendering container.
  • If you're calling this API from your frontend code and logging the error response, you'd see this message in your browser's console.

Keeping Invariants in Production Builds

If we want to keep invariants within production builds, we could build a simple library function to perform an environment check first, such as the following.

const isDev = process.env.NODE_ENV !== 'production';
export const customInvariant = (condition, message) => {
  if (isDev) {
    invariant(condition, message);
  }
};

At least now, you can keep the invariant in place in your code in a manner that doesn’t interfere too much.

The most important thing to remember in all this is that by doing the necessary checks up front, we can improve the code that reaches our QA and Production environments and hopefully reduce the number of bugs reported back.



Meet David Austin

Development Team Lead | Sitecore Technology MVP x 3

📷🕹️👪

David is a decorated Development Team Lead with Sitecore Technology MVP and Coveo MVP awards, as well as Sitecore CDP & Personalize Certified. He's worked in IT for 25 years; everything ranging from Developer to Business Analyst to Group Lead helping manage everything from Intranet and Internet sites to facility management and application support. David is a dedicated family man who loves to spend time with his girls. He's also an avid photographer and loves to explore new places.

Connect with David