A few weeks ago I was working through some TypeScript code and ran into an ESLint error that stopped me cold. The rule was flagging a type assertion as unnecessary when removing it would actually break the code.
The rule was @typescript-eslint/no-unnecessary-type-assertion. The version was 8.59+. And the error was wrong.
Here's what triggered it:
const array: object[] = [{}]
let e2: object | undefined
e2 ??= array[1] // TypeScript now narrows e2 to object
if (e2) console.log("defined") // no-unnecessary-condition fires here
So you have two problems now. e2 ??= array[1] narrows e2 to object after the assignment. Then no-unnecessary-condition fires on the if because TypeScript thinks e2 is always truthy. The obvious fix is to widen the assertion back:
e3 ??= array[1] as object | undefined
Except that made no-unnecessary-type-assertion fire instead. Both rules were complaining. The type system had you trapped.
I found this reported as issue #12245 in the typescript-eslint repo, tagged "accepting prs." So I decided to look at what was actually going on.
Why the assertion is actually necessary
The core problem is TypeScript's flow analysis. When you write x ??= someExpression, TypeScript doesn't just compute the result. It narrows x based on what happened. After x ??= array[1], TypeScript concludes that x must now hold a value of the type returned by array[1], which without noUncheckedIndexedAccess is just object.
That narrowing is the problem. The whole point of writing array[1] as object | undefined is to opt out of that narrowing and keep x typed as object | undefined. The assertion isn't unnecessary at all. It's doing real work.
But the rule didn't know that. It was looking at the types on both sides and concluding: the asserted type (object | undefined) is broader than the actual type at that point (object), so the assertion is widening, not necessary. Flag it.
The rule applies the same logic here that it should apply in a plain assignment like:
let x: string | undefined = "hello" as string | undefined // actually unnecessary
That's a correct flag. The string literal is already string, so asserting string | undefined is genuinely pointless. But the logical assignment case is different because of what TypeScript does to the variable type after the operation.
The fix
The fix is to skip the unnecessary-assertion check whenever the assertion is on the right-hand side of a logical assignment operator. Logical assignment in JavaScript is &&=, ||=, and ??=. If your assertion is sitting there, it's almost certainly there because you're fighting flow narrowing, not because you're confused.
function isRightHandSideOfLogicalAssignment(
node: TSESTree.TSAsExpression | TSESTree.TSTypeAssertion,
): boolean {
const { parent } = node;
return (
parent.type === AST_NODE_TYPES.AssignmentExpression &&
parent.right === node &&
(parent.operator === '&&=' ||
parent.operator === '||=' ||
parent.operator === '??=')
);
}
This function checks if the assertion node is on the right side of one of the three logical assignment operators. Then it gets added to the list of early-return conditions in the main check:
if (
isInDestructuringDeclaration(node) ||
isPropertyInProblematicContext(node) ||
isAssignmentInNonStatementContext(node) ||
isRightHandSideOfLogicalAssignment(node) ||
isArgumentToOverloadedFunction(node)
) {
return true;
}
Returning true here means "keep this assertion, don't flag it." So now if your assertion is on the right side of any of the three logical assignment operators, the rule leaves it alone.
The tests cover all three operators:
const array: object[] = [{}];
let nullish: object | undefined;
nullish ??= array[1] as object | undefined; // no error
let falsy: object | undefined;
falsy ||= array[1] as object | undefined; // no error
let truthy: object | undefined = {};
truthy &&= array[1] as object | undefined; // no error
Getting it merged
I opened PR #12278. Brad Zacher reviewed it and approved. It shipped in the next release.
The actual code change was 27 lines added, zero deleted. That's pretty common for rule fixes. The hard part is understanding the rule's internal model well enough to know where to add the check, not writing the check itself.
What I took away from this
Reading ESLint rule source code is more approachable than it looks. The codebase is well-organized: each rule is a self-contained file, the AST node types are documented, and the test format makes it obvious what "valid" versus "invalid" code means for that rule.
If you're using typescript-eslint and a rule fires on code that you're sure is correct, it's worth checking the issues list. Rules that touch TypeScript's flow analysis are hard to get exactly right, and maintainers tag bugs "accepting prs" specifically because they want help covering edge cases.
The fix that matters here isn't the code. It's understanding that TypeScript's flow analysis does something after logical assignment that the rule's type-comparison logic doesn't account for. Once you see that, the fix is obvious.













