Skip to content

Commit efcd1bf

Browse files
committed
feat(rules): add prefer-nullish-coalescing rule 0️⃣
- Add new lint rule for preferring nullish coalescing over logical OR - Add test suite with 30+ test cases - Add with examples and usage guidelines - Add type definitions for LiteralNode and LogicalExpressionNode - Add utility functions for AST node type checking - Update plugin exports to include new rule - Update README with rule documentation link
1 parent 342b835 commit efcd1bf

File tree

8 files changed

+485
-2
lines changed

8 files changed

+485
-2
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Deno lint plugin collection for identifying and reporting on patterns found in c
77
- [`async-function-naming`](./examples/async-function-naming.md) - Enforces async functions to have 'Async' suffix
88
- [`explicit-parameter-types`](./examples/explicit-parameter-types.md) - Requires explicit type annotations on parameters
99
- [`explicit-return-types`](./examples/explicit-return-types.md) - Requires explicit return type annotations
10+
- [`prefer-nullish-coalescing`](./examples/prefer-nullish-coalescing.md) - Prefers nullish coalescing (??) over logical OR (||) for null/undefined checks
1011
- [`require-error-handling`](./examples/require-error-handling.md) - Ensures Deno file operations are properly awaited
1112

1213
## Installation
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# Expected Behavior: `prefer-nullish-coalescing` Rule
2+
3+
This rule enforces the use of nullish coalescing operator (`??`) over logical OR (`||`) when dealing with null or undefined values, particularly when the right-hand side is a falsy but not nullish value.
4+
5+
## ❌ Invalid Examples
6+
7+
```typescript
8+
// Logical OR with empty string (falsy but not nullish)
9+
const displayName = user.name || ''
10+
const message = input || ''
11+
const title = config.title || ''
12+
13+
// Logical OR with zero (falsy but not nullish)
14+
const count = items.length || 0
15+
const index = searchIndex || 0
16+
const timeout = settings.timeout || 0
17+
18+
// Logical OR with false (falsy but not nullish)
19+
const enabled = feature.flag || false
20+
const visible = config.show || false
21+
const required = field.required || false
22+
23+
// Complex expressions with falsy values
24+
const result = user?.name || '' || 'Anonymous'
25+
const config = {
26+
apiUrl: process.env.API_URL || '',
27+
timeout: settings.timeout || 0,
28+
debug: process.env.DEBUG || false
29+
}
30+
31+
// Function parameters with falsy defaults
32+
function createUser(name = user.name || '', age = user.age || 0) {
33+
return { name, age }
34+
}
35+
36+
// Object properties with falsy values
37+
const user = {
38+
name: input.name || '',
39+
count: data.count || 0,
40+
active: status.active || false
41+
}
42+
43+
// Template literals with falsy values
44+
const greeting = `Hello ${user.name || ''}!`
45+
const status = `Count: ${items.length || 0}`
46+
const message = `Debug: ${config.debug || false}`
47+
```
48+
49+
## ✅ Valid Examples
50+
51+
```typescript
52+
// Nullish coalescing with empty string (correct)
53+
const displayName = user.name ?? ''
54+
const message = input ?? ''
55+
const title = config.title ?? ''
56+
57+
// Nullish coalescing with zero (correct)
58+
const count = items.length ?? 0
59+
const index = searchIndex ?? 0
60+
const timeout = settings.timeout ?? 0
61+
62+
// Nullish coalescing with false (correct)
63+
const enabled = feature.flag ?? false
64+
const visible = config.show ?? false
65+
const required = field.required ?? false
66+
67+
// Complex expressions with nullish coalescing
68+
const result = (user?.name ?? '') || 'Anonymous'
69+
const config = {
70+
apiUrl: process.env.API_URL ?? '',
71+
timeout: settings.timeout ?? 0,
72+
debug: process.env.DEBUG ?? false
73+
}
74+
75+
// Function parameters with nullish coalescing defaults
76+
function createUser(name = user.name ?? '', age = user.age ?? 0) {
77+
return { name, age }
78+
}
79+
80+
// Object properties with nullish coalescing
81+
const user = {
82+
name: input.name ?? '',
83+
count: data.count ?? 0,
84+
active: status.active ?? false
85+
}
86+
87+
// Template literals with nullish coalescing
88+
const greeting = `Hello ${user.name ?? ''}!`
89+
const status = `Count: ${items.length ?? 0}`
90+
const message = `Debug: ${config.debug ?? false}`
91+
92+
// Logical OR with non-falsy values (not affected by this rule)
93+
const name = user.name || 'Anonymous'
94+
const count = items.length || 10
95+
const enabled = feature.flag || true
96+
97+
// Logical AND (not affected by this rule)
98+
const result = user && user.name
99+
const isValid = input && input.length > 0
100+
101+
// Already using nullish coalescing (not affected by this rule)
102+
const displayName = user.name ?? 'Anonymous'
103+
const count = items.length ?? 10
104+
const enabled = feature.flag ?? true
105+
```
106+
107+
## Rule Scope
108+
109+
This rule **only applies to**:
110+
111+
- ✅ Logical OR (`||`) operators where the right-hand side is a falsy but not nullish value:
112+
- Empty string (`""`)
113+
- Zero (`0`)
114+
- Boolean `false`
115+
116+
This rule **does NOT apply to**:
117+
118+
- ❌ Logical OR (`||`) with non-falsy values (`"hello"`, `1`, `true`)
119+
- ❌ Logical AND (`&&`) operators
120+
- ❌ Nullish coalescing (`??`) operators
121+
- ❌ Other logical operators (`!`, `&&`, `??`)
122+
123+
## Why This Rule Matters
124+
125+
The logical OR (`||`) operator treats **all** falsy values as "missing":
126+
127+
- `""` (empty string) → treated as missing
128+
- `0` (zero) → treated as missing
129+
- `false` (boolean) → treated as missing
130+
131+
The nullish coalescing (`??`) operator only treats `null` and `undefined` as missing:
132+
133+
- `""` (empty string) → preserved
134+
- `0` (zero) → preserved
135+
- `false` (boolean) → preserved
136+
137+
## Auto-fix Behavior
138+
139+
The rule provides auto-fix suggestions that replace `||` with `??`:
140+
141+
- `value || ""``value ?? ""`
142+
- `count || 0``count ?? 0`
143+
- `flag || false``flag ?? false`
144+
- `(user?.name || "") || false``(user?.name ?? "") || false` (first occurrence)

src/mod.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { LintPlugin } from '@app/types.ts'
22
import { asyncFunctionNamingRule } from '@rules/async-function-naming.ts'
33
import { explicitParameterTypesRule } from '@rules/explicit-parameter-types.ts'
44
import { explicitReturnTypesRule } from '@rules/explicit-return-types.ts'
5+
import { preferNullishCoalescingRule } from '@rules/prefer-nullish-coalescing.ts'
56
import { requireErrorHandlingRule } from '@rules/require-error-handling.ts'
67

78
/**
@@ -13,6 +14,7 @@ const plugin: LintPlugin = {
1314
'async-function-naming': asyncFunctionNamingRule,
1415
'explicit-parameter-types': explicitParameterTypesRule,
1516
'explicit-return-types': explicitReturnTypesRule,
17+
'prefer-nullish-coalescing': preferNullishCoalescingRule,
1618
'require-error-handling': requireErrorHandlingRule
1719
}
1820
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type { ASTNode, LintContext, LintFixer, LogicalExpressionNode } from '@app/types.ts'
2+
import { isLiteral, isLogicalExpression } from '@shared/expression.ts'
3+
4+
/**
5+
* Checks if a logical expression should use nullish coalescing instead of logical OR.
6+
* @param node - The logical expression node to check
7+
* @returns True if the expression should use nullish coalescing, false otherwise
8+
*/
9+
function shouldUseNullishCoalescing(node: LogicalExpressionNode): boolean {
10+
if (node.operator !== '||') {
11+
return false
12+
}
13+
const rightSide = node.right
14+
if (isLiteral(rightSide)) {
15+
const value = rightSide.value
16+
if (value === '' || value === 0 || value === false) {
17+
return true
18+
}
19+
}
20+
return false
21+
}
22+
23+
/**
24+
* Lint rule for preferring nullish coalescing over logical OR.
25+
*/
26+
export const preferNullishCoalescingRule = {
27+
/**
28+
* Creates the lint rule implementation.
29+
* @param context - The Deno lint context for reporting issues and fixes
30+
* @returns Object containing visitor functions for AST node types
31+
*/
32+
create(context: LintContext): Record<string, (node: ASTNode) => void> {
33+
return {
34+
/**
35+
* Visitor function for logical expressions.
36+
* @param node - The AST node representing a logical expression
37+
*/
38+
LogicalExpression(node: ASTNode): void {
39+
if (!isLogicalExpression(node)) {
40+
return
41+
}
42+
if (shouldUseNullishCoalescing(node)) {
43+
context.report({
44+
node,
45+
message:
46+
'Prefer nullish coalescing (??) over logical OR (||) for null/undefined checks',
47+
fix(fixer: LintFixer): unknown {
48+
const original = context.sourceCode.getText(node)
49+
const newText = original.replace('||', '??')
50+
return fixer.replaceText(node, newText)
51+
}
52+
})
53+
}
54+
}
55+
}
56+
}
57+
}

src/shared/expression.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import type {
44
CallExpressionNode,
55
FunctionDeclarationNode,
66
FunctionExpressionNode,
7+
LiteralNode,
8+
LogicalExpressionNode,
79
MethodDefinitionNode
810
} from '@app/types.ts'
911

@@ -51,3 +53,21 @@ export function isFunctionExpression(node: ASTNode): node is FunctionExpressionN
5153
export function isMethodDefinition(node: ASTNode): node is MethodDefinitionNode {
5254
return node.type === 'MethodDefinition'
5355
}
56+
57+
/**
58+
* Type guard to check if a node is a logical expression.
59+
* @param node - The AST node to check
60+
* @returns True if the node is a logical expression, false otherwise
61+
*/
62+
export function isLogicalExpression(node: ASTNode): node is LogicalExpressionNode {
63+
return node.type === 'LogicalExpression'
64+
}
65+
66+
/**
67+
* Type guard to check if a node is a literal.
68+
* @param node - The AST node to check
69+
* @returns True if the node is a literal, false otherwise
70+
*/
71+
export function isLiteral(node: ASTNode): node is LiteralNode {
72+
return node.type === 'Literal'
73+
}

src/types.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,30 @@ export interface TSTypeAliasDeclarationNode {
205205
id?: { name: string }
206206
}
207207

208+
/**
209+
* AST node representing a literal value (string, number, boolean, etc.).
210+
*/
211+
export interface LiteralNode {
212+
/** Type identifier for literal nodes */
213+
type: 'Literal'
214+
/** The literal value */
215+
value: string | number | boolean | null
216+
}
217+
218+
/**
219+
* AST node representing a logical expression (&&, ||, ??).
220+
*/
221+
export interface LogicalExpressionNode {
222+
/** Type identifier for logical expressions */
223+
type: 'LogicalExpression'
224+
/** The logical operator */
225+
operator: '&&' | '||' | '??'
226+
/** Left side of the expression */
227+
left: ASTNode
228+
/** Right side of the expression */
229+
right: ASTNode
230+
}
231+
208232
/**
209233
* AST node representing a variable declarator.
210234
*/
@@ -229,6 +253,8 @@ export type ASTNode =
229253
| FunctionDeclarationNode
230254
| FunctionExpressionNode
231255
| InterfaceDeclarationNode
256+
| LiteralNode
257+
| LogicalExpressionNode
232258
| MethodDefinitionNode
233259
| TSEnumDeclarationNode
234260
| TSEnumMemberNode

tests/index.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,17 @@ export function verifyAutoFix(
2222
rulesId: string,
2323
code: string,
2424
expectedFixedCode: string,
25-
description: string
25+
description: string,
26+
expectedCount = 1
2627
): void {
2728
const diagnostics = Deno.lint.runPlugin(plugin, 'test.ts', code)
2829
const ruleDiagnostics = diagnostics.filter((d) => d.id === rulesId)
2930
console.log(`${description} diagnostics:`, JSON.stringify(ruleDiagnostics, null, 2))
30-
assertEquals(ruleDiagnostics.length, 1, `Expected 1 diagnostic for ${description}`)
31+
assertEquals(
32+
ruleDiagnostics.length,
33+
expectedCount,
34+
`Expected ${expectedCount} diagnostic for ${description}`
35+
)
3136
const diagnostic = ruleDiagnostics[0]
3237
assertEquals(diagnostic.fix?.length, 1, `Should have auto-fix available for ${description}`)
3338
const fix = diagnostic.fix?.[0]

0 commit comments

Comments
 (0)