Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tidy-planes-stay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@biomejs/biome": patch
---

Fixed [#9810](https://github.com/biomejs/biome/issues/9810): `noMisleadingReturnType` incorrectly flagging nested object literals with widened properties.
189 changes: 105 additions & 84 deletions crates/biome_js_analyze/src/lint/nursery/no_misleading_return_type.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ pub struct RuleState {
returns: Vec<Type>,
}

/// Maximum iterations for type graph traversal to guard against infinite loops on cyclic types.
const MAX_TYPE_TRAVERSAL_ITERATIONS: usize = 50;

impl Rule for NoMisleadingReturnType {
type Query = Typed<AnyFunctionLikeWithReturnType>;
type State = RuleState;
Expand Down Expand Up @@ -412,110 +415,128 @@ fn is_literal_of_primitive(ty: &Type) -> bool {
/// Checks whether annotation differs from returns only by property-level
/// literal widening that contextual typing would handle.
fn is_only_property_literal_widening(annotation: &Type, returns: &[Type]) -> bool {
if let TypeData::Tuple(ann_tuple) = &**annotation {
return returns.iter().all(|inferred| {
let TypeData::Tuple(inf_tuple) = &**inferred else {
return false;
};
let ann_elems = ann_tuple.elements();
let inf_elems = inf_tuple.elements();
if ann_elems.len() != inf_elems.len() || ann_elems.is_empty() {
returns.iter().all(|inferred| {
let mut stack: Vec<(Type, Type)> = vec![(annotation.clone(), inferred.clone())];
let mut has_widening = false;
let mut iterations = 0usize;

while let Some((annotated, inferred)) = stack.pop() {
iterations += 1;
if iterations > MAX_TYPE_TRAVERSAL_ITERATIONS {
return false;
}
let mut has_widening = false;
for (ann_elem, inf_elem) in ann_elems.iter().zip(inf_elems.iter()) {
let ann_ty = annotation.resolve(&ann_elem.ty);
let inf_ty = inferred.resolve(&inf_elem.ty);
match (ann_ty, inf_ty) {
(Some(a), Some(b)) => {
if types_match(&a, &b) {
continue;
}
if is_base_type_of_literal(&a, &b) {
has_widening = true;
} else {
return false;

if let TypeData::Tuple(annotated_tuple) = &*annotated {
let TypeData::Tuple(inferred_tuple) = &*inferred else {
return false;
};
let annotated_elements = annotated_tuple.elements();
let inferred_elements = inferred_tuple.elements();
if annotated_elements.len() != inferred_elements.len()
|| annotated_elements.is_empty()
{
return false;
}
for (annotated_element, inferred_element) in
annotated_elements.iter().zip(inferred_elements.iter())
{
match (
annotated.resolve(&annotated_element.ty),
inferred.resolve(&inferred_element.ty),
) {
(Some(annotated_type), Some(inferred_type)) => {
if types_match(&annotated_type, &inferred_type) {
continue;
}
if is_base_type_of_literal(&annotated_type, &inferred_type) {
has_widening = true;
} else {
stack.push((annotated_type, inferred_type));
}
}
_ => return false,
}
_ => return false,
}
continue;
}
has_widening
});
}

let (TypeData::Object(ann_obj), _) = (&**annotation, ()) else {
return false;
};

if ann_obj.members.is_empty() {
return false;
}
let TypeData::Object(annotated_object) = &*annotated else {
return false;
};
if annotated_object.members.is_empty() {
return false;
}

returns.iter().all(|inferred| {
let inf_members = match &**inferred {
TypeData::Object(obj) => &obj.members,
TypeData::Literal(lit) => match lit.as_ref() {
Literal::Object(obj_lit) => obj_lit.members(),
let inferred_members = match &*inferred {
TypeData::Object(object) => &object.members,
TypeData::Literal(literal) => match literal.as_ref() {
Literal::Object(object_literal) => object_literal.members(),
_ => return false,
},
_ => return false,
},
_ => return false,
};

if inf_members.is_empty() {
return false;
}

let mut has_widening = false;
};
if inferred_members.is_empty() {
return false;
}

let ann_index_sig = ann_obj.members.iter().find(|m| {
matches!(m.kind, biome_js_type_info::TypeMemberKind::IndexSignature(_))
});
if let Some(sig_member) = ann_index_sig
&& let Some(sig_value_ty) = annotation.resolve(&sig_member.ty) {
let mut sig_has_widening = false;
let all_ok = inf_members.iter().all(|inf_m| {
if let Some(inf_ty) = annotation.resolve(&inf_m.ty) {
if types_match(&sig_value_ty, &inf_ty) {
let annotated_index_signature = annotated_object.members.iter().find(|member| {
matches!(
member.kind,
biome_js_type_info::TypeMemberKind::IndexSignature(_)
)
});
if let Some(index_signature_member) = annotated_index_signature
&& let Some(index_signature_value_type) =
annotated.resolve(&index_signature_member.ty)
{
let mut index_signature_has_widening = false;
let all_inferred_covered = inferred_members.iter().all(|inferred_member| {
if let Some(inferred_type) = inferred.resolve(&inferred_member.ty) {
if types_match(&index_signature_value_type, &inferred_type) {
return true;
}
if is_base_type_of_literal(&sig_value_ty, &inf_ty) {
sig_has_widening = true;
if is_base_type_of_literal(&index_signature_value_type, &inferred_type) {
index_signature_has_widening = true;
return true;
}
}
false
});
return all_ok && sig_has_widening;
if !(all_inferred_covered && index_signature_has_widening) {
return false;
}
has_widening = true;
continue;
}

for ann_member in ann_obj.members.iter() {
let ann_name = match &ann_member.kind {
biome_js_type_info::TypeMemberKind::Named(name)
| biome_js_type_info::TypeMemberKind::NamedOptional(name) => name,
_ => continue,
};

let inf_member = inf_members.iter().find(|m| m.kind.has_name(ann_name));
let Some(inf_member) = inf_member else {
return false;
};

let ann_ty = annotation.resolve(&ann_member.ty);
let inf_ty = annotation.resolve(&inf_member.ty);

match (ann_ty, inf_ty) {
(Some(a), Some(b)) => {
if types_match(&a, &b) {
continue;
}
if is_base_type_of_literal(&a, &b) {
has_widening = true;
} else {
return false;
for annotated_member in annotated_object.members.iter() {
let annotated_name = match &annotated_member.kind {
biome_js_type_info::TypeMemberKind::Named(name)
| biome_js_type_info::TypeMemberKind::NamedOptional(name) => name,
_ => continue,
};
let Some(inferred_member) = inferred_members
.iter()
.find(|member| member.kind.has_name(annotated_name))
else {
return false;
};
match (
annotated.resolve(&annotated_member.ty),
inferred.resolve(&inferred_member.ty),
) {
(Some(annotated_type), Some(inferred_type)) => {
if types_match(&annotated_type, &inferred_type) {
continue;
}
if is_base_type_of_literal(&annotated_type, &inferred_type) {
has_widening = true;
} else {
stack.push((annotated_type, inferred_type));
}
}
_ => return false,
}
_ => return false,
}
}

Expand Down Expand Up @@ -888,7 +909,7 @@ fn is_nonunion_wider(annotated: &Type, inferred: &Type) -> bool {

while let Some((ann, inf)) = stack.pop() {
iterations += 1;
if iterations > 50 {
if iterations > MAX_TYPE_TRAVERSAL_ITERATIONS {
return false;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,7 @@ declare function getStr(): string;
function letReassignStr(): string { let s = "default"; if (Math.random() > 0.5) s = getStr(); return s; }
declare function getNum(): number;
const arrowLetReassign = (): number => { let n = 0; if (Math.random() > 0.5) n = getNum(); return n; };

// Nested object widening without as const — tsc widens `true` to `boolean` at any depth
function nestedObj(): { inner: { flag: boolean } } { return { inner: { flag: true } }; }
function nestedTuple(): [{ ok: boolean }] { return [{ ok: true }]; }
Original file line number Diff line number Diff line change
Expand Up @@ -156,4 +156,8 @@ function letReassignStr(): string { let s = "default"; if (Math.random() > 0.5)
declare function getNum(): number;
const arrowLetReassign = (): number => { let n = 0; if (Math.random() > 0.5) n = getNum(); return n; };

// Nested object widening without as const — tsc widens `true` to `boolean` at any depth
function nestedObj(): { inner: { flag: boolean } } { return { inner: { flag: true } }; }
function nestedTuple(): [{ ok: boolean }] { return [{ ok: true }]; }

```
Loading