DEV Community

subaru
subaru

Posted on AI-assisted

What I Learned Implementing noReactObjectTypeAsDefaultProp in Biome

I contributed the noReactObjectTypeAsDefaultProp lint rule to Biome.

The issue was to port eslint-plugin-react/no-object-type-as-default-prop to Biome. This post explains what the rule checks and what I learned about Rust while implementing it.

What the rule checks

The rule reports expressions such as object and array literals when they are used as default values for destructured React props.

function Component({ items = [], config = {} }) {
  return <div>{items.length}</div>;
}
Enter fullscreen mode Exit fullscreen mode

JavaScript creates a new value each time it evaluates an object or array literal.

[] === []; // false
{} === {}; // false
Enter fullscreen mode Exit fullscreen mode

Using one as a prop default therefore creates a different reference on every render. That can break memoization or make dependency-based logic run more often than intended.

The rule detects:

  • object and array literals
  • arrow functions and function expressions
  • class expressions
  • new expressions
  • JSX elements
  • regular-expression literals
  • Symbol() calls

Primitive defaults such as strings, numbers, and booleans remain valid.

Reading Rust before writing Rust

Biome's contribution guide led me to state my intention on the issue before starting. I had almost no practical Rust experience, so I first read The Rust Programming Language.

After reading the book, I could follow the syntax, but many of the rules were still separate pieces of knowledge. They started to connect only when I had to read Biome's code, fix compiler errors, and respond to review feedback.

Ownership and borrowing in practice

Every value in Rust has an owner. When the owner leaves its scope, the value is dropped. Passing a String to another function may move ownership.

fn consume(message: String) {
    println!("{message}");
}

fn main() {
    let message = String::from("hello");
    consume(message);
    println!("{message}");
}
Enter fullscreen mode Exit fullscreen mode

The compiler explains exactly what happened:

error[E0382]: borrow of moved value: `message`
  |
  |     consume(message);
  |             ------- value moved here
  |
  |     println!("{message}");
  |                ^^^^^^^ value borrowed here after move
Enter fullscreen mode Exit fullscreen mode

If a function only needs to read a value, it can borrow a reference instead.

fn length(message: &str) -> usize {
    message.len()
}

fn main() {
    let message = String::from("hello");
    println!("{}", length(&message));
    println!("{message}");
}
Enter fullscreen mode Exit fullscreen mode

The output is:

5
hello
Enter fullscreen mode Exit fullscreen mode

A mutable reference uses &mut:

fn append_world(message: &mut String) {
    message.push_str(", world");
}

fn main() {
    let mut message = String::from("hello");
    append_world(&mut message);
    println!("{message}");
}
Enter fullscreen mode Exit fullscreen mode
hello, world
Enter fullscreen mode Exit fullscreen mode

JavaScript and Go normally let a garbage collector reclaim unused memory. Rust uses a different approach: the compiler checks ownership and borrowing rules. A garbage collector is not unsafe; Rust simply reaches memory safety through a different design.

While traversing Biome's syntax tree, I often needed to inspect nodes without owning them. I gradually learned to ask whether a reference was enough instead of moving or cloning a value by default.

Traversing Biome's syntax tree

Biome operates on a concrete syntax tree (CST), which preserves details such as comments and whitespace. A lint rule does not search JavaScript as text. It walks typed syntax nodes.

Conceptually, this rule follows this path:

React component
  └─ function parameters
      └─ object binding pattern
          └─ each property
              └─ default-value expression
Enter fullscreen mode Exit fullscreen mode

The real implementation must handle function declarations, arrow functions, function expressions, functions wrapped in memo or forwardRef, and default-exported functions. These forms use different syntax-node types. I used Biome's existing ReactComponentInfo and brought the different forms to one point where the rule could obtain the props object pattern.

When I did not know a node or method name, I searched existing rules with rg and found examples that handled similar syntax. Learning how to navigate a large codebase was as important as learning Rust syntax.

Why Option and Result fit syntax trees

A syntax tree does not always contain the node a rule is looking for. A function may not destructure its first parameter, and a file may contain incomplete syntax.

Rust represents an optional value with Option<T>: Some(T) when a value exists and None when it does not.

fn find_even(numbers: &[i32]) -> Option<i32> {
    numbers.iter().copied().find(|number| number % 2 == 0)
}

fn main() {
    println!("{:?}", find_even(&[1, 10, 3]));
    println!("{:?}", find_even(&[1, 3, 5]));
}
Enter fullscreen mode Exit fullscreen mode
Some(10)
None
Enter fullscreen mode Exit fullscreen mode

Result<T, E> represents success as Ok(T) and a recoverable failure as Err(E).

fn parse_port(value: &str) -> Result<u16, std::num::ParseIntError> {
    value.parse()
}
Enter fullscreen mode Exit fullscreen mode

These types make absence and failure visible in a function's signature. Reading them in the Rust Book was useful, but using them to traverse syntax nodes made their purpose concrete.

Returning multiple diagnostics with Vec

One component can contain several invalid defaults:

function Component({ items = [], options = {}, render = () => null }) {
  return null;
}
Enter fullscreen mode Exit fullscreen mode

Stopping after the first match would make users fix the same line repeatedly. The rule therefore returns a Vec of findings. A Vec<T> is a growable collection of values with the same type. An empty vector represents no diagnostics; three entries represent three diagnostics.

fn collect_even(numbers: &[i32]) -> Vec<i32> {
    let mut results = Vec::new();
    for number in numbers {
        if number % 2 == 0 {
            results.push(*number);
        }
    }
    results
}
Enter fullscreen mode Exit fullscreen mode

Understanding what ? returns from

The ? operator unwraps a successful Result or present Option. On Err or None, it returns early from the current function or closure.

fn parse_and_double(value: &str) -> Result<u32, std::num::ParseIntError> {
    let number = value.parse::<u32>()?;
    Ok(number * 2)
}
Enter fullscreen mode Exit fullscreen mode

The word current matters. The rule processes object properties with filter_map. Its closure returns an Option, so ? can skip the current property without ending the entire rule run.

let defaults = object_pattern
    .properties()
    .filter_map(|property| {
        let property = property.ok()?;
        let default_value = find_default_value(property)?;
        let kind = ForbiddenDefaultKind::from_expression(&default_value)?;
        Some(ForbiddenDefault { default_value, kind })
    })
    .collect();
Enter fullscreen mode Exit fullscreen mode

This also explains why ? cannot be used directly in a function returning Vec. It is not merely shorthand; its control flow is tied to the return type.

Moving behavior closer to the type

I initially put the expression classification in a free function:

fn forbidden_default_kind(
    expression: &AnyJsExpression,
) -> Option<ForbiddenDefaultKind> {
    // classify the expression
}
Enter fullscreen mode Exit fullscreen mode

After review, I moved it to ForbiddenDefaultKind:

impl ForbiddenDefaultKind {
    fn from_expression(expression: &AnyJsExpression) -> Option<Self> {
        // classify the expression
    }
}
Enter fullscreen mode Exit fullscreen mode

Now the type that represents the classification also knows how to derive itself from an expression. Both versions can work, but the second location makes the call site easier to read and keeps the classification detail near the type it creates.

Moving non-React behavior into shared syntax code

I also added parameter extraction to the React component module at first. A reviewer pointed out that the operation was not React-specific. Extracting parameters from JavaScript functions can be useful to other analyses too.

I moved it to a shared extension on AnyJsFunction in biome_js_syntax:

impl AnyJsFunction {
    pub fn parenthesized_parameters(&self) -> Option<JsParameters> {
        // normalize the different function forms
    }
}
Enter fullscreen mode Exit fullscreen mode

That review changed how I look at module boundaries. It is not enough to place code where the first caller happens to live. The operation should live at the layer that owns the concept.

Handling aliased defaults

Object destructuring can rename a property:

function Component({ items: localItems = [] }) {
  return <div>{localItems.length}</div>;
}
Enter fullscreen mode Exit fullscreen mode

My first implementation handled only shorthand properties such as { items = [] }. The original ESLint rule also reports aliased defaults. Its property handling does not assume shorthand syntax.

Biome represents shorthand and aliased binding properties with different node types. I updated the rule to extract defaults from both JsObjectBindingPatternShorthandProperty and JsObjectBindingPatternProperty, then added a regression test for the aliased form.

Diagnostics are part of the implementation

A lint rule must do more than identify a node. Its message needs to help a user decide what to do next.

The review introduced me to three parts of a useful diagnostic:

  1. what is wrong
  2. why it matters
  3. how to fix it

I also replaced unexplained wording such as “reference type” with the concrete behavior: the value is newly created on every render. A technically correct message is not automatically an actionable one.

What changed in my understanding of Rust

After reading the Rust Book, ownership, borrowing, Option, Result, Vec, and ? still felt like separate language features.

In Biome they form one flow. The rule borrows syntax nodes, represents missing nodes with Option, receives parsing failures as Result, uses ? inside a closure to skip non-matches, and collects findings into a Vec.

Rust now feels less like a language with many restrictions and more like a language that makes ownership, absence, and failure visible in the code. Reading the book gave me the vocabulary. Working in a real codebase—and receiving feedback from both the compiler and reviewers—made the vocabulary useful.

References

This article was edited with AI assistance. I checked the technical explanations against the merged implementation, tests, review discussions, and the official Rust documentation.

Top comments (0)