diff --git a/packages/comparisons/src/data.json b/packages/comparisons/src/data.json index 57fa601f6..2508d75bf 100644 --- a/packages/comparisons/src/data.json +++ b/packages/comparisons/src/data.json @@ -16573,7 +16573,8 @@ "flint": { "name": "strictBooleanExpressions", "plugin": "ts", - "preset": "logical" + "preset": "logical", + "status": "implemented" } }, { diff --git a/packages/site/src/content/docs/rules/ts/strictBooleanExpressions.mdx b/packages/site/src/content/docs/rules/ts/strictBooleanExpressions.mdx new file mode 100644 index 000000000..54428c249 --- /dev/null +++ b/packages/site/src/content/docs/rules/ts/strictBooleanExpressions.mdx @@ -0,0 +1,86 @@ +--- +description: "Disallow non-boolean types in boolean contexts that may cause unexpected behavior." +title: "strictBooleanExpressions" +topic: "rules" +--- + +import { Tabs, TabItem } from "@astrojs/starlight/components"; +import { RuleEquivalents } from "~/components/RuleEquivalents"; +import RuleSummary from "~/components/RuleSummary.astro"; + + + +JavaScript allows any value in boolean contexts like `if` statements, ternary expressions, and logical negation. +While primitives like strings and numbers have well-understood coercion rules, certain types in boolean contexts can indicate bugs or unclear code. + +This rule reports: + +- Using `any` in a boolean context, which defeats type safety +- Nullable booleans (`boolean | null` or `boolean | undefined`), where `null`/`undefined` silently coerce to `false` +- Non-nullable objects that are always truthy, making the condition redundant + +## Examples + + + + +```ts +declare const value: any; +if (value) { +} + +declare const flag: boolean | null; +if (flag) { +} + +const config = { enabled: true }; +if (config) { +} +``` + + + + +```ts +declare const flag: boolean; +if (flag) { +} + +declare const text: string; +if (text) { +} + +declare const count: number; +if (count) { +} + +declare const item: object | null; +if (item) { +} + +declare const flag: boolean | null; +if (flag === true) { +} +if (flag ?? false) { +} +``` + + + + +## Options + +This rule is not configurable. + +## When Not To Use It + +If your codebase intentionally relies on truthy/falsy coercion for nullable booleans or uses `any` types extensively, you may want to disable this rule. + +## Further Reading + +- [TypeScript Handbook: Narrowing](https://www.typescriptlang.org/docs/handbook/2/narrowing.html) +- [MDN: Falsy values](https://developer.mozilla.org/en-US/docs/Glossary/Falsy) + +## Equivalents in Other Linters + + diff --git a/packages/ts/src/plugin.ts b/packages/ts/src/plugin.ts index 0fd9c4c7e..b4471e3e3 100644 --- a/packages/ts/src/plugin.ts +++ b/packages/ts/src/plugin.ts @@ -222,6 +222,7 @@ import selfComparisons from "./rules/selfComparisons.ts"; import sequences from "./rules/sequences.ts"; import shadowedRestrictedNames from "./rules/shadowedRestrictedNames.ts"; import sparseArrays from "./rules/sparseArrays.ts"; +import strictBooleanExpressions from "./rules/strictBooleanExpressions.ts"; import symbolDescriptions from "./rules/symbolDescriptions.ts"; import typeofComparisons from "./rules/typeofComparisons.ts"; import unassignedVariables from "./rules/unassignedVariables.ts"; @@ -464,6 +465,7 @@ export const ts = createPlugin({ sequences, shadowedRestrictedNames, sparseArrays, + strictBooleanExpressions, symbolDescriptions, typeofComparisons, unassignedVariables, diff --git a/packages/ts/src/rules/strictBooleanExpressions.test.ts b/packages/ts/src/rules/strictBooleanExpressions.test.ts new file mode 100644 index 000000000..e5168cc4e --- /dev/null +++ b/packages/ts/src/rules/strictBooleanExpressions.test.ts @@ -0,0 +1,170 @@ +import { ruleTester } from "./ruleTester.ts"; +import rule from "./strictBooleanExpressions.ts"; + +ruleTester.describe(rule, { + invalid: [ + { + code: ` +declare const value: any; +if (value) {} +`, + snapshot: ` +declare const value: any; +if (value) {} + ~~~~~ + Using \`any\` in a boolean context can cause unexpected behavior. +`, + }, + { + code: ` +declare const value: any; +while (value) {} +`, + snapshot: ` +declare const value: any; +while (value) {} + ~~~~~ + Using \`any\` in a boolean context can cause unexpected behavior. +`, + }, + { + code: ` +declare const value: any; +do {} while (value); +`, + snapshot: ` +declare const value: any; +do {} while (value); + ~~~~~ + Using \`any\` in a boolean context can cause unexpected behavior. +`, + }, + { + code: ` +declare const value: any; +for (; value; ) {} +`, + snapshot: ` +declare const value: any; +for (; value; ) {} + ~~~~~ + Using \`any\` in a boolean context can cause unexpected behavior. +`, + }, + { + code: ` +declare const value: any; +const result = value ? 1 : 0; +`, + snapshot: ` +declare const value: any; +const result = value ? 1 : 0; + ~~~~~ + Using \`any\` in a boolean context can cause unexpected behavior. +`, + }, + { + code: ` +declare const value: any; +const negated = !value; +`, + snapshot: ` +declare const value: any; +const negated = !value; + ~~~~~ + Using \`any\` in a boolean context can cause unexpected behavior. +`, + }, + { + code: ` +declare const flag: boolean | null; +if (flag) {} +`, + snapshot: ` +declare const flag: boolean | null; +if (flag) {} + ~~~~ + Nullable booleans require explicit null checks in conditions. +`, + }, + { + code: ` +declare const flag: boolean | undefined; +if (flag) {} +`, + snapshot: ` +declare const flag: boolean | undefined; +if (flag) {} + ~~~~ + Nullable booleans require explicit null checks in conditions. +`, + }, + { + code: ` +declare const flag: boolean | null | undefined; +if (flag) {} +`, + snapshot: ` +declare const flag: boolean | null | undefined; +if (flag) {} + ~~~~ + Nullable booleans require explicit null checks in conditions. +`, + }, + { + code: ` +const config = { enabled: true }; +if (config) {} +`, + snapshot: ` +const config = { enabled: true }; +if (config) {} + ~~~~~~ + This condition is always truthy. +`, + }, + { + code: ` +function getHandler() { return () => {}; } +if (getHandler()) {} +`, + snapshot: ` +function getHandler() { return () => {}; } +if (getHandler()) {} + ~~~~~~~~~~~~ + This condition is always truthy. +`, + }, + { + code: ` +class Service {} +const service = new Service(); +if (service) {} +`, + snapshot: ` +class Service {} +const service = new Service(); +if (service) {} + ~~~~~~~ + This condition is always truthy. +`, + }, + ], + valid: [ + `const flag = true; if (flag) {}`, + `const flag = false; if (flag) {}`, + `declare const flag: boolean; if (flag) {}`, + `const text = "hello"; if (text) {}`, + `declare const text: string; if (text) {}`, + `const count = 42; if (count) {}`, + `declare const count: number; if (count) {}`, + `declare const item: object | null; if (item) {}`, + `declare const item: object | undefined; if (item) {}`, + `declare const nullable: string | null; if (nullable != null) {}`, + `declare const flag: boolean | null; if (flag === true) {}`, + `declare const flag: boolean | undefined; if (flag ?? false) {}`, + `const items = [1, 2, 3]; if (items.length) {}`, + `declare const items: number[]; if (items.length) {}`, + `declare const getter: (() => void) | undefined; if (getter) {}`, + ], +}); diff --git a/packages/ts/src/rules/strictBooleanExpressions.ts b/packages/ts/src/rules/strictBooleanExpressions.ts new file mode 100644 index 000000000..6c59d7a61 --- /dev/null +++ b/packages/ts/src/rules/strictBooleanExpressions.ts @@ -0,0 +1,193 @@ +import { + type TypeScriptFileServices, + typescriptLanguage, +} from "@flint.fyi/typescript-language"; +import * as tsutils from "ts-api-utils"; +import ts from "typescript"; + +import { ruleCreator } from "./ruleCreator.ts"; + +export default ruleCreator.createRule(typescriptLanguage, { + about: { + description: + "Reports non-boolean types in boolean contexts that may cause unexpected behavior.", + id: "strictBooleanExpressions", + presets: ["logical"], + }, + messages: { + alwaysTruthy: { + primary: "This condition is always truthy.", + secondary: [ + "Non-nullable objects are always truthy in JavaScript.", + "This makes the condition redundant or indicates a logic error.", + ], + suggestions: ["Remove this condition or check a specific property."], + }, + anyInCondition: { + primary: + "Using `any` in a boolean context can cause unexpected behavior.", + secondary: [ + "The value could be any type, making the condition unpredictable.", + "This defeats TypeScript's type safety guarantees.", + ], + suggestions: [ + "Add explicit type narrowing or use `Boolean()` conversion.", + ], + }, + nullableBoolean: { + primary: "Nullable booleans require explicit null checks in conditions.", + secondary: [ + "Using `boolean | null` or `boolean | undefined` directly can hide null coercion.", + "The nullish value coerces to `false`, which may be unintentional.", + ], + suggestions: [ + "Use `value === true` or `value ?? false` for explicit handling.", + ], + }, + }, + setup(context) { + function checkCondition( + expression: ts.Expression, + services: TypeScriptFileServices, + ) { + const { sourceFile, typeChecker } = services; + const type = typeChecker.getTypeAtLocation(expression); + + if (tsutils.isTypeFlagSet(type, ts.TypeFlags.Any)) { + context.report({ + message: "anyInCondition", + range: { + begin: expression.getStart(sourceFile), + end: expression.getEnd(), + }, + }); + return; + } + + if (isNullableBoolean(type)) { + context.report({ + message: "nullableBoolean", + range: { + begin: expression.getStart(sourceFile), + end: expression.getEnd(), + }, + }); + return; + } + + if (isAlwaysTruthyObject(type, typeChecker)) { + context.report({ + message: "alwaysTruthy", + range: { + begin: expression.getStart(sourceFile), + end: expression.getEnd(), + }, + }); + } + } + + function isNullableBoolean(type: ts.Type) { + if (!type.isUnion()) { + return false; + } + + let hasBoolean = false; + let hasNullOrUndefined = false; + + for (const constituent of type.types) { + if ( + tsutils.isTypeFlagSet( + constituent, + ts.TypeFlags.BooleanLiteral | ts.TypeFlags.Boolean, + ) + ) { + hasBoolean = true; + } + if ( + tsutils.isTypeFlagSet( + constituent, + ts.TypeFlags.Null | ts.TypeFlags.Undefined, + ) + ) { + hasNullOrUndefined = true; + } + } + + return hasBoolean && hasNullOrUndefined; + } + + function isAlwaysTruthyObject(type: ts.Type, checker: ts.TypeChecker) { + if (type.isUnion()) { + return false; + } + + if ( + tsutils.isTypeFlagSet( + type, + ts.TypeFlags.Any | + ts.TypeFlags.Unknown | + ts.TypeFlags.Boolean | + ts.TypeFlags.BooleanLiteral | + ts.TypeFlags.String | + ts.TypeFlags.StringLiteral | + ts.TypeFlags.Number | + ts.TypeFlags.NumberLiteral | + ts.TypeFlags.BigInt | + ts.TypeFlags.BigIntLiteral | + ts.TypeFlags.Void | + ts.TypeFlags.Undefined | + ts.TypeFlags.Null | + ts.TypeFlags.Never | + ts.TypeFlags.Enum | + ts.TypeFlags.EnumLiteral, + ) + ) { + return false; + } + + if (checker.isArrayType(type) || checker.isTupleType(type)) { + return false; + } + + const symbol = type.getSymbol(); + if (symbol) { + const name = symbol.getName(); + if (name === "Array" || name === "ReadonlyArray") { + return false; + } + } + + return ( + tsutils.isTypeFlagSet(type, ts.TypeFlags.Object) || + tsutils.isTypeFlagSet(type, ts.TypeFlags.NonPrimitive) + ); + } + + return { + visitors: { + ConditionalExpression: (node, services) => { + checkCondition(node.condition, services); + }, + DoStatement: (node, services) => { + checkCondition(node.expression, services); + }, + ForStatement: (node, services) => { + if (node.condition) { + checkCondition(node.condition, services); + } + }, + IfStatement: (node, services) => { + checkCondition(node.expression, services); + }, + PrefixUnaryExpression: (node, services) => { + if (node.operator === ts.SyntaxKind.ExclamationToken) { + checkCondition(node.operand, services); + } + }, + WhileStatement: (node, services) => { + checkCondition(node.expression, services); + }, + }, + }; + }, +});