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);
+ },
+ },
+ };
+ },
+});