How TypeScript’s Compiler Helped Us Prevent Redux Performance Issues
Client
Dev Experience
Performance

How TypeScript’s Compiler Helped Us Prevent Redux Performance Issues

Omri Lavi
Omri Lavi

Performance is in Our DNA

At monday.com, performance isn’t just a priority—it’s a core principle. With millions of users, even small inefficiencies can scale into larger performance issues, directly affecting the user experience.

A key part of maintaining performance is how we manage state. Selectors in libraries like Redux and Zustand help retrieve specific data efficiently, preventing unnecessary re-renders. But here’s the catch: if a selector doesn’t return the same reference for the same input, it can cause performance bottlenecks by triggering unnecessary updates.

To avoid these pitfalls, we built a tool that automatically detects suboptimal selectors by ensuring that they return consistent references—an essential step in keeping our app fast and responsive.

The Cost of Bad Selectors

Our goal as monday’s DevTools group is to give engineers the best tools to succeed, regardless of their level of expertise. This is especially important when working with selectors, where subtle performance issues can easily go unnoticed, even by experienced developers.

For example, consider the following selector:

const getActiveUsers = (state) => 
    Object.values(state.users).filter(user => user.active);

While this seems simple enough, Object.values creates a new array every time the selector is called, even if the state.users object hasn’t changed. This unnecessary object creation can trigger re-renders and slow down the application.

We wanted to prevent these kinds of performance issues by detecting patterns like this early. Our solution was to build a tool that would automatically flag selectors like the one above, ensuring that developers don’t accidentally introduce inefficiencies into the codebase.

Our first attempt was a naive solution:

  • We started by detecting all selectors and calling them with various arguments.
  • If the selector threw an error, we would ignore it. However, if a value was returned, we’d call the selector again with the same arguments and check if the reference was the same.

While this naive approach worked for some simpler cases, we quickly realized it wasn’t enough for the more complex selectors in our codebase:

  • False Negatives: Advanced selectors often involve complex argument patterns that our naive solution couldn’t handle effectively. This meant the tool was missing critical issues with these selectors.
  • Runtime Errors: Since we couldn’t guarantee that the arguments we supplied were valid, we encountered runtime errors that led to incomplete or inaccurate results.

The turning point came when we realized there were more advanced use cases we couldn’t handle naively. Simply calling the selectors with arbitrary arguments wasn’t enough—we needed a more reliable way to ensure that the inputs we supplied were valid.

TypeScript to the Rescue

After recognizing the limitations of our naive solution, we realized that the key to solving the problem was ensuring that the arguments we supplied to the selectors were valid. This would prevent runtime errors and allow us to focus on the actual performance behavior of the selectors.

This is where TypeScript came into play. By leveraging TypeScript’s powerful type system, we could programmatically generate valid arguments for each selector, ensuring that we could reliably test whether calling the selector with the same input would return the same reference. This gave us the confidence to automatically detect suboptimal selectors without risking false negatives or runtime errors.

We explored several existing tools, including tst-reflect, which allows you to reflect on TypeScript types at runtime. However, it required changes to our tsconfig that didn’t fit well with our existing setup. We wanted a solution that integrated seamlessly without modifying our build configuration. Ultimately, we decided to build our own tool using the TypeScript Compiler API.

Building our own solution gave us the flexibility we needed, but it wasn’t without challenges. From handling spread parameters to complex state objects, we encountered several hurdles. In the end, the experience helped us learn a lot about how to tackle these advanced TypeScript features effectively.

Teaching TypeScript to Understand Selectors

To reliably generate arguments for selectors, we needed to extract the types of each selector’s parameters using the TypeScript Compiler API. Here’s a simplified example that shows how we used TypeScript to extract type information from a file:

import * as ts from 'typescript';

const program = ts.createProgram(['path/to/your/file.ts'], {});
const checker = program.getTypeChecker();
const sourceFile = program.getSourceFile('path/to/your/file.ts');

ts.forEachChild(sourceFile, (node) => {
  if (!(ts.isFunctionDeclaration(node) && node.name)) return;

  const symbol = checker.getSymbolAtLocation(node.name);
  const functionType = checker.getTypeOfSymbolAtLocation(symbol!, node);

  // Extracting the parameters' types
  const signature = functionType.getCallSignatures()[0];
  signature.getParameters().forEach((param) => {
    const paramType = checker.getTypeOfSymbolAtLocation(param, param.valueDeclaration!);
    console.log(`Type of parameter: ${checker.typeToString(paramType)}`);
  });
});

This code does the following:

  • Creates a TypeScript Program: Initializes a TypeScript program from your source files.
  • Extracts Type Information: Uses the type checker to retrieve the type information for each function’s parameters.
  • Outputs Parameter Types: Logs the type of each parameter, helping us understand the expected input.

For example, if you have the following function:

export function repeat(str: string, times: number) {
  return new Array(times + 1).join(str);
}

The output would show:

Type of parameter: string
Type of parameter: number

However, as we dug deeper into more complex selectors, the parameter types became more intricate. Handling union types, recursive types, and generics required a more advanced approach.

In the next section, we’ll explore how we tackled these complexities by programmatically generating valid arguments for all types—ensuring reliable testing for even the most complicated cases.

Creating Valid Arguments Programmatically

TypeScript uses TypeFlags to represent different types in its type system. These flags are binary, and we can inspect them using bitwise operations to identify the type and generate appropriate default values. Here’s where our generateArgumentOfType function comes in—it helps generate valid arguments for each selector based on these flags.

Here’s a simplified example of how we generate default arguments for basic types like strings, numbers, booleans, and literals:

import * as ts from 'typescript';

function generateArgumentOfType(type: ts.Type) {
  // Handle primitive types like strings, numbers, and booleans
  if (type.flags & ts.TypeFlags.String) {
    return ''; // Return an empty string for string types
  }
  if (type.flags & ts.TypeFlags.Number) {
    return 0; // Return 0 for number types
  }
  if (type.flags & ts.TypeFlags.Boolean || type.flags & ts.TypeFlags.BooleanLiteral) {
    return false; // Return false for boolean types
  }
  if (type.flags & ts.TypeFlags.Null) {
    return null; // Return null for null types
  }
  if (type.flags & ts.TypeFlags.Undefined) {
    return undefined; // Return undefined for undefined types
  }

  // Handle literal types like string and number literals
  if (type.isStringLiteral()) {
    return type.value; // Handle string literals (including enums)
  }
  if (type.isNumberLiteral()) {
    return type.value; // Handle number literals
  }

  // Further handling for other types...
}

This argument generation process is expandable and can be adapted to handle more complex types, such as arrays, objects, and custom types.

This approach worked well for most selectors. However, certain cases — such as spread parameters (...params) — required more advanced handling. Spread parameters are frequently used in libraries like Reselect and Re-reselect to accept multiple inputs and optimize caching. Although TypeScript treats them similarly to arrays, spread parameters behave differently in function signatures, making them difficult to handle programmatically. To deal with these complex cases, we used TypeScript’s dotDotDotToken to identify and expand spread parameters. Tools like TS AST Viewer were critical for visualizing the AST and implementing this logic. Our previous experience with AST manipulation in Babel transformations helped us navigate these challenges effectively.

Testing Selectors Like a Pro

To ensure that our tool reliably detected suboptimal selectors, we designed it to automatically wrap each selector in a test suite. This allowed us to systematically verify if selectors were memoized properly—i.e., if they returned the same reference when called with the same inputs.

For each file containing selectors, the tool generates a test suite. It checks every selector by calling it twice with generated arguments and ensuring that the reference remains the same. If the references differ, the selector is flagged as needing optimization.

Here’s the simplified test code:

import { getExportedFunctionSignatures, generateParametersForFunction } from './type-util';

// Describe the overall test suite
describe('selectors-same-reference', () => {
  const filesToFunctions = getExportedFunctionSignatures();

  // Loop through each file and selector
  [...filesToFunctions.entries()].forEach(([fileName, selectorFunc]) => {
    describe(`Selectors in file ${fileName}`, () => {
      let fileExports: any;

      // Dynamically import each file before running tests
      beforeAll(async () => {
        fileExports = await import(fileName);
      });

      // Test each selector in the file
      [...selectorFunc.entries()].forEach(([selectorName, selectorParamTypes]) => {
        it(`should return the same reference when called twice for selector "${selectorName}"`, () => {
          const selector = fileExports[selectorName];

          // Generate valid parameters for the selector
          const params = generateParametersForFunction(selectorParamTypes);

          // Call the selector twice with the same arguments
          const firstResult = selector(...params);
          const secondResult = selector(...params);

          // Check if both results return the same reference
          if (firstResult !== secondResult) {
            const error = new Error(`In file ${fileName}, selector ${selectorName} returned different results for the same arguments. \
This may cause performance issues. Consider using memoization tools like reselect.`);
            error.stack = undefined;
            throw error;
          }
        });
      });
    });
  });
});

Here’s what’s happening in this code:

  1. Dynamic Import and Selector Testing:
    For each file, we dynamically import its selectors and test them. For each selector, we generate arguments using generateParametersForFunction.
  2. Testing Reference Consistency: The selector is called twice with the same arguments. If the results don’t share the same reference, the test fails, indicating that the selector isn’t properly memoized.

Here’s an example of a selector that will fail the test:

// A poorly implemented selector that always returns a new reference
export const getItems = (state: { items: Record<string, object> }) => {
  return Object.values(state.items); // Creates a new array every time it's called
};

If this function is tested, the test suite would produce an error like this:

Error: In file ./selectors/items.ts, selector getItems returned different results for the same arguments. 
This may cause performance issues. Consider using memoization tools like reselect.

A Tool to Test the Tool

Before applying the tool to our entire codebase, we created file fixtures to test the tool’s ability to detect arguments and generate valid properties for selectors. These fixtures included a range of edge cases, such as union types, tuples, and optional parameters. By comparing the generated arguments against the expected results, we were able to verify that the tool was functioning correctly. This process also helped us identify the tool’s limitations—knowing where improvements were needed and where certain cases could be classified as technical limitations (for now).

Once we were confident that the tool handled the edge cases correctly, we integrated it into our codebase and ran it on real-world selectors.

Real-World Success

Since integrating this test suite, we’ve already detected several suboptimal selectors in our codebase that weren’t returning consistent references. These findings allowed our developers to optimize the selectors early, improving the overall performance of our application.

By automating this testing process, we’ve reduced the burden on individual engineers to manually check for performance bottlenecks. The tool has proven its value by catching issues that might have otherwise gone unnoticed, and it will continue to help us ensure the efficiency of our selectors moving forward.

TypeScript: The Gift That Keeps on Giving

Building this tool pushed us to think beyond traditional uses of TypeScript. It opened the door to creating smarter developer tools that optimize performance while maintaining scalability. What excites us most is the potential for even more advanced applications of TypeScript’s Compiler API.

We’re already exploring possibilities like:

  • Refactoring Tools: Automatically identify safe refactorings based on type information.
  • Automating Quality Checks: Analyze types to enforce consistency and maintainability.
  • Generating Valid Test Data: Create comprehensive test coverage based on type definitions for complex projects.

These are just a few examples, but the possibilities are endless.If you’re as excited about the potential of TypeScript as we are, and you find this kind of work interesting, we’d love to have you join us at monday.com! We’re always on the lookout for passionate engineers who love solving technical challenges and pushing the boundaries of what’s possible with tools like TypeScript.

Omri Lavi
Client Infrastructure Tech Lead @ monday.com