Skip to content

Commit 4a32b63

Browse files
authored
feat(lint): add ignorePrimitives option to useNullishCoalescing (#10798)
1 parent 2c3e82d commit 4a32b63

14 files changed

Lines changed: 656 additions & 0 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@biomejs/biome": patch
3+
---
4+
5+
Added the option `ignorePrimitives` to [useNullishCoalescing](https://biomejs.dev/linter/rules/use-nullish-coalescing/). When enabled, Biome ignores `||`, `||=`, and ternary expressions whose non-nullish operands are all primitives the option opts out of. Use `true` to ignore all primitives, or an object selecting `string`, `number`, `boolean`, or `bigint`.

crates/biome_js_analyze/src/lint/nursery/use_nullish_coalescing.rs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,39 @@ declare_lint_rule! {
190190
/// const r = Boolean(a || b);
191191
/// ```
192192
///
193+
/// ### ignorePrimitives
194+
///
195+
/// Ignore `||`, `||=`, and ternary expressions when every non-nullish variant
196+
/// of the operand is a primitive the option opts out of. Use `true` to ignore
197+
/// all primitives, or an object selecting `string`, `number`, `boolean`, or
198+
/// `bigint`. Default: none.
199+
///
200+
/// ```json,options
201+
/// {
202+
/// "options": {
203+
/// "ignorePrimitives": { "string": true }
204+
/// }
205+
/// }
206+
/// ```
207+
///
208+
/// #### Invalid
209+
///
210+
/// Primitive kinds that are not opted out of are still reported.
211+
///
212+
/// ```ts,expect_diagnostic,use_options,file=invalid-primitives.ts
213+
/// declare const count: number | null;
214+
/// const value = count || 0;
215+
/// ```
216+
///
217+
/// #### Valid
218+
///
219+
/// A `string` operand is not reported when `string` is ignored.
220+
///
221+
/// ```ts,use_options,file=valid-primitives.ts
222+
/// declare const name: string | null;
223+
/// const value = name || 'default';
224+
/// ```
225+
///
193226
pub UseNullishCoalescing {
194227
version: "2.4.5",
195228
name: "useNullishCoalescing",
@@ -445,6 +478,10 @@ fn run_logical_or(
445478
return None;
446479
}
447480

481+
if options.has_any_ignore_primitives() && should_ignore_for_primitives(options, &left_ty) {
482+
return None;
483+
}
484+
448485
let can_fix =
449486
is_safe_type_for_replacement(&left_ty) && is_safe_syntax_context_for_replacement(logical);
450487

@@ -491,6 +528,10 @@ fn run_logical_or_assignment(
491528
return None;
492529
}
493530

531+
if options.has_any_ignore_primitives() && should_ignore_for_primitives(options, &left_ty) {
532+
return None;
533+
}
534+
494535
let can_fix = is_safe_type_for_replacement(&left_ty);
495536

496537
Some(UseNullishCoalescingState::LogicalOrAssignment {
@@ -524,6 +565,44 @@ fn is_possibly_nullish(ty: &biome_js_type_info::Type) -> bool {
524565
}
525566
}
526567

568+
/// Returns `true` when every non-nullish variant of `ty` is a primitive that the
569+
/// configured `ignorePrimitives` option suppresses. A non-union type is never
570+
/// ignored here, since the diagnostic only fires once the operand is a nullish
571+
/// union in the first place.
572+
fn should_ignore_for_primitives(
573+
options: &UseNullishCoalescingOptions,
574+
ty: &biome_js_type_info::Type,
575+
) -> bool {
576+
if !ty.is_union() {
577+
return false;
578+
}
579+
ty.flattened_union_variants()
580+
.filter(|variant| !variant.conditional_semantics().is_nullish())
581+
.all(|variant| is_ignored_primitive(options, &variant))
582+
}
583+
584+
/// Maps a resolved primitive (or primitive literal) type to its matching
585+
/// `ignorePrimitives` selector. Non-primitive types are never ignored.
586+
fn is_ignored_primitive(
587+
options: &UseNullishCoalescingOptions,
588+
ty: &biome_js_type_info::Type,
589+
) -> bool {
590+
ty.resolved_data().is_some_and(|data| match data.as_raw_data() {
591+
TypeData::String => options.should_ignore_primitive_string(),
592+
TypeData::Number => options.should_ignore_primitive_number(),
593+
TypeData::Boolean => options.should_ignore_primitive_boolean(),
594+
TypeData::BigInt => options.should_ignore_primitive_bigint(),
595+
TypeData::Literal(literal) => match literal.as_ref() {
596+
biome_js_type_info::Literal::String(_) => options.should_ignore_primitive_string(),
597+
biome_js_type_info::Literal::Number(_) => options.should_ignore_primitive_number(),
598+
biome_js_type_info::Literal::Boolean(_) => options.should_ignore_primitive_boolean(),
599+
biome_js_type_info::Literal::BigInt(_) => options.should_ignore_primitive_bigint(),
600+
_ => false,
601+
},
602+
_ => false,
603+
})
604+
}
605+
527606
fn is_safe_syntax_context_for_replacement(logical: &JsLogicalExpression) -> bool {
528607
// Without parentheses, swapping `||` for `??` next to another logical operator changes precedence.
529608
let is_parenthesized = logical
@@ -723,6 +802,14 @@ fn run_ternary(
723802
let (checked_expr, fallback_expr, is_positive, check_kind) =
724803
check_ternary_nullish_pattern(&test, &consequent, &alternate)?;
725804

805+
let options = ctx.options();
806+
if options.has_any_ignore_primitives() {
807+
let checked_ty = ctx.type_of_expression(&checked_expr);
808+
if should_ignore_for_primitives(options, &checked_ty) {
809+
return None;
810+
}
811+
}
812+
726813
// The fix is unsafe when the checked expression contains calls or `new`, because
727814
// the ternary evaluates it twice (test + branch) while `??` evaluates it once.
728815
let has_side_effects = contains_call_or_new_expression(&checked_expr);
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"$schema": "../../../../../../packages/@biomejs/biome/configuration_schema.json",
3+
"linter": {
4+
"rules": {
5+
"nursery": {
6+
"useNullishCoalescing": {
7+
"level": "error",
8+
"options": {
9+
"ignorePrimitives": true
10+
}
11+
}
12+
}
13+
}
14+
}
15+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Tests for ignorePrimitives: true
2+
// `||` and `||=` on primitive-typed operands should NOT report; non-primitives still do.
3+
4+
declare const s: string | null;
5+
declare const n: number | null;
6+
declare const b: boolean | null;
7+
declare const big: bigint | null;
8+
declare const fallbackS: string;
9+
declare const fallbackN: number;
10+
11+
// primitive left operands: suppressed
12+
const r1 = s || fallbackS;
13+
const r2 = n || fallbackN;
14+
const r3 = b || false;
15+
const r4 = big || 0n;
16+
17+
// ||= on a primitive: suppressed
18+
declare let assigned: string | null;
19+
assigned ||= fallbackS;
20+
21+
// literal-typed unions: suppressed (exercises the TypeData::Literal arm)
22+
declare const litStr: 'a' | 'b' | null;
23+
declare const litNum: 0 | 1 | null;
24+
declare const litBool: true | null;
25+
declare const litBig: 1n | 2n | null;
26+
const r5 = litStr || 'c';
27+
const r6 = litNum || 2;
28+
const r7 = litBool || false;
29+
const r8 = litBig || 3n;
30+
31+
// non-primitive (object) left operand: SHOULD still report
32+
declare const obj: { x: string } | null;
33+
declare const fallbackObj: { x: string };
34+
const r9 = obj || fallbackObj;
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
source: crates/biome_js_analyze/tests/spec_tests.rs
3+
expression: ignorePrimitivesAll.ts
4+
---
5+
# Input
6+
```ts
7+
// Tests for ignorePrimitives: true
8+
// `||` and `||=` on primitive-typed operands should NOT report; non-primitives still do.
9+
10+
declare const s: string | null;
11+
declare const n: number | null;
12+
declare const b: boolean | null;
13+
declare const big: bigint | null;
14+
declare const fallbackS: string;
15+
declare const fallbackN: number;
16+
17+
// primitive left operands: suppressed
18+
const r1 = s || fallbackS;
19+
const r2 = n || fallbackN;
20+
const r3 = b || false;
21+
const r4 = big || 0n;
22+
23+
// ||= on a primitive: suppressed
24+
declare let assigned: string | null;
25+
assigned ||= fallbackS;
26+
27+
// literal-typed unions: suppressed (exercises the TypeData::Literal arm)
28+
declare const litStr: 'a' | 'b' | null;
29+
declare const litNum: 0 | 1 | null;
30+
declare const litBool: true | null;
31+
declare const litBig: 1n | 2n | null;
32+
const r5 = litStr || 'c';
33+
const r6 = litNum || 2;
34+
const r7 = litBool || false;
35+
const r8 = litBig || 3n;
36+
37+
// non-primitive (object) left operand: SHOULD still report
38+
declare const obj: { x: string } | null;
39+
declare const fallbackObj: { x: string };
40+
const r9 = obj || fallbackObj;
41+
42+
```
43+
44+
# Diagnostics
45+
```
46+
ignorePrimitivesAll.ts:34:16 lint/nursery/useNullishCoalescing FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━
47+
48+
i Use ?? instead of ||.
49+
50+
32 │ declare const obj: { x: string } | null;
51+
33 │ declare const fallbackObj: { x: string };
52+
> 34 │ const r9 = obj || fallbackObj;
53+
│ ^^
54+
35 │
55+
56+
i The || operator checks for all falsy values (including 0, '', and false), while ?? only checks for null and undefined.
57+
58+
i This rule is still being actively worked on, so it may be missing features or have rough edges. Visit https://github.com/biomejs/biome/issues/8043 for more information or to report possible bugs.
59+
60+
i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information.
61+
62+
i Safe fix: Replace || with ??.
63+
64+
32 32 │ declare const obj: { x: string } | null;
65+
33 33 │ declare const fallbackObj: { x: string };
66+
34 │ - const·r9·=·obj·||·fallbackObj;
67+
34 │ + const·r9·=·obj·??·fallbackObj;
68+
35 35 │
69+
70+
71+
```
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"$schema": "../../../../../../packages/@biomejs/biome/configuration_schema.json",
3+
"linter": {
4+
"rules": {
5+
"nursery": {
6+
"useNullishCoalescing": {
7+
"level": "error",
8+
"options": {
9+
"ignorePrimitives": false
10+
}
11+
}
12+
}
13+
}
14+
}
15+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// Tests for ignorePrimitives: false
2+
// The option is present but disabled, so primitive operands still report.
3+
4+
declare const s: string | null;
5+
declare const n: number | null;
6+
7+
const r1 = s || 'x';
8+
const r2 = n || 0;
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
source: crates/biome_js_analyze/tests/spec_tests.rs
3+
expression: ignorePrimitivesDisabled.ts
4+
---
5+
# Input
6+
```ts
7+
// Tests for ignorePrimitives: false
8+
// The option is present but disabled, so primitive operands still report.
9+
10+
declare const s: string | null;
11+
declare const n: number | null;
12+
13+
const r1 = s || 'x';
14+
const r2 = n || 0;
15+
16+
```
17+
18+
# Diagnostics
19+
```
20+
ignorePrimitivesDisabled.ts:7:14 lint/nursery/useNullishCoalescing ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
21+
22+
i Use ?? instead of ||.
23+
24+
5 │ declare const n: number | null;
25+
6 │
26+
> 7 │ const r1 = s || 'x';
27+
│ ^^
28+
8 │ const r2 = n || 0;
29+
9 │
30+
31+
i The || operator checks for all falsy values (including 0, '', and false), while ?? only checks for null and undefined.
32+
33+
i This rule is still being actively worked on, so it may be missing features or have rough edges. Visit https://github.com/biomejs/biome/issues/8043 for more information or to report possible bugs.
34+
35+
i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information.
36+
37+
38+
```
39+
40+
```
41+
ignorePrimitivesDisabled.ts:8:14 lint/nursery/useNullishCoalescing ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
42+
43+
i Use ?? instead of ||.
44+
45+
7 │ const r1 = s || 'x';
46+
> 8 │ const r2 = n || 0;
47+
│ ^^
48+
9 │
49+
50+
i The || operator checks for all falsy values (including 0, '', and false), while ?? only checks for null and undefined.
51+
52+
i This rule is still being actively worked on, so it may be missing features or have rough edges. Visit https://github.com/biomejs/biome/issues/8043 for more information or to report possible bugs.
53+
54+
i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information.
55+
56+
57+
```
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"$schema": "../../../../../../packages/@biomejs/biome/configuration_schema.json",
3+
"linter": {
4+
"rules": {
5+
"nursery": {
6+
"useNullishCoalescing": {
7+
"level": "error",
8+
"options": {
9+
"ignorePrimitives": {
10+
"string": true,
11+
"number": true
12+
}
13+
}
14+
}
15+
}
16+
}
17+
}
18+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Tests for ignorePrimitives: { string: true, number: true }
2+
// string/number operands suppressed; boolean/bigint still report.
3+
4+
declare const s: string | null;
5+
declare const n: number | null;
6+
declare const b: boolean | null;
7+
declare const big: bigint | null;
8+
9+
// ignored kinds: suppressed
10+
const r1 = s || 'x';
11+
const r2 = n || 0;
12+
13+
// non-ignored kinds: SHOULD still report
14+
const r3 = b || false;
15+
const r4 = big || 0n;
16+
17+
// string-literal union: suppressed (string ignored, exercises the literal arm)
18+
declare const litStr: 'a' | 'b' | null;
19+
const r5 = litStr || 'c';
20+
21+
// every non-nullish variant ignored: suppressed
22+
declare const sn: string | number | null;
23+
const r6 = sn || 'x';
24+
25+
// only some variants ignored (boolean not opted out): SHOULD still report
26+
declare const sb: string | boolean | null;
27+
const r7 = sb || 'x';
28+
29+
// ternary on a string operand: suppressed (ignorePrimitives applies to ternary too)
30+
const r8 = s != null ? s : 'x';
31+
32+
// ternary on a boolean operand: SHOULD still report
33+
const r9 = b != null ? b : false;

0 commit comments

Comments
 (0)