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>;
}
JavaScript creates a new value each time it evaluates an object or array literal.
[] === []; // false
{} === {}; // false
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
-
newexpressions - 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}");
}
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
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}");
}
The output is:
5
hello
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}");
}
hello, world
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
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]));
}
Some(10)
None
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()
}
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;
}
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
}
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)
}
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();
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
}
After review, I moved it to ForbiddenDefaultKind:
impl ForbiddenDefaultKind {
fn from_expression(expression: &AnyJsExpression) -> Option<Self> {
// classify the expression
}
}
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
}
}
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>;
}
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:
- what is wrong
- why it matters
- 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
- Biome PR #10634
- Biome issue #7656
- The Rust Programming Language: Ownership
- The Rust Programming Language: References and Borrowing
- The
Optionenum - Recoverable Errors with
Result - Storing Lists of Values with Vectors
- Rust Reference: the question mark operator
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)