Stop Silent Signal Bugs Before They Haunt Your Angular App — A Developer's Guide to Creating Custom ESLint Rules
Introduction
Here's a question for you: How many times this week have you forgotten to add ()
when accessing an Angular Signal? 🤔
If you're like me, the answer is probably "more than I'd like to admit." And here's the kicker — TypeScript won't save you. The compiler stays silent while your app quietly misbehaves, logging mysterious signal objects instead of actual values.
I've been guilty of this more than once. Picture this: You're debugging for 20 minutes, wondering why your template shows [object Object]
instead of that beautiful counter value you expected. Sound familiar?
By the end of this article, you'll learn:
- Why Signal invocation mistakes are so sneaky and dangerous
- How to build a custom ESLint rule that catches these bugs automatically
- Real-world testing strategies to ensure your rule works perfectly
- How to integrate this safeguard into your team's workflow
Plus, I'll share the exact code you can copy-paste today. Let's dive in! 👇
The Problem: Silent Mistakes with Signals
Angular Signals are fantastic — until you forget those two tiny characters: ()
. Here's what I mean:
// What you meant to write
const count = signal(0);
console.log(count()); // Output: 0 ✅
// What you actually wrote at 5 PM on Friday
const count = signal(0);
console.log(count); // Output: WritableSignal<number> { ... } 😱
Quick question: Have you ever shipped this bug to production? Drop a comment below — I promise you're not alone! 💬
The worst part? This won't throw an error. Your app keeps running, but with corrupted data flow. In templates, you might see [object Object]
. In calculations, you get NaN
. In conditionals? Complete chaos.
Why These Bugs Are Developer Kryptonite
- No compile-time errors — TypeScript thinks you want the signal object
- Subtle runtime behavior — The app doesn't crash; it just acts weird
- Hard to trace — Especially in large codebases with complex data flow
- Easy to miss in code reviews — Even experienced devs overlook it
I once spent 45 minutes debugging a "broken" computed signal, only to realize I'd forgotten ()
in three different places. Never again.
Introducing the Custom ESLint Rule: Your Silent Guardian
What if your editor could tap you on the shoulder and say, "Hey, you forgot ()
on that signal"? That's exactly what we're building.
Our custom ESLint rule will:
- 🔍 Detect when you reference a signal without calling it
- ⚠️ Show a clear warning in your editor
- 🔧 Offer auto-fix suggestions
- 🚀 Run in CI to catch mistakes before merge
Think of it as your personal code bodyguard, always watching for signal mishaps.
How the ESLint Rule Works Under the Hood
Let's peek behind the curtain. Our rule uses Abstract Syntax Tree (AST) traversal to understand your code structure:
// eslint-rules/require-signal-invocation.js
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Require Angular signals to be invoked with ()',
category: 'Possible Errors',
recommended: true
},
fixable: 'code',
schema: [],
messages: {
missingInvocation: 'Signal "{{name}}" must be invoked with (). Did you forget ()?'
}
},
create(context) {
const signalVariables = new Set();
return {
// Track signal declarations
VariableDeclarator(node) {
if (node.init && node.init.type === 'CallExpression') {
const calleeName = node.init.callee.name;
// Check if it's a signal, computed, or similar
if (['signal', 'computed', 'toSignal'].includes(calleeName)) {
signalVariables.add(node.id.name);
}
}
},
// Check signal usage
Identifier(node) {
// Skip if it's already being called
if (node.parent.type === 'CallExpression' &&
node.parent.callee === node) {
return;
}
// Skip property access (e.g., signal.set, signal.update)
if (node.parent.type === 'MemberExpression' &&
node.parent.object === node) {
return;
}
// Flag missing invocation
if (signalVariables.has(node.name)) {
context.report({
node,
messageId: 'missingInvocation',
data: { name: node.name },
fix(fixer) {
return fixer.insertTextAfter(node, '()');
}
});
}
}
};
}
};
Breaking Down the Magic ✨
-
Variable Tracking: We maintain a
Set
of all signal variable names - AST Node Analysis: We check every identifier in your code
-
Smart Detection: We skip legitimate uses (like
signal.set()
) -
Auto-Fix Generation: We automatically suggest adding
()
Pro tip: Want to extend this for your custom signal wrappers? Add them to the includes
array! What other patterns would you add? Let me know below! 👇
Testing the Rule: Trust but Verify
Never ship untested code — especially linting rules that touch your entire codebase. Here's how to test it properly:
// eslint-rules/tests/require-signal-invocation.test.js
const { RuleTester } = require('eslint');
const rule = require('../require-signal-invocation');
const ruleTester = new RuleTester({
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module'
}
});
ruleTester.run('require-signal-invocation', rule, {
valid: [
// Correct usage with invocation
{
code: `
const count = signal(0);
console.log(count());
`
},
// Method calls are allowed
{
code: `
const count = signal(0);
count.set(5);
count.update(v => v + 1);
`
},
// Passing to effect is valid
{
code: `
const count = signal(0);
effect(() => console.log(count()));
`
}
],
invalid: [
// Missing invocation in console.log
{
code: `
const count = signal(0);
console.log(count);
`,
errors: [{
messageId: 'missingInvocation',
data: { name: 'count' }
}],
output: `
const count = signal(0);
console.log(count());
`
},
// Missing in template binding simulation
{
code: `
const user = signal({ name: 'John' });
const displayName = user.name;
`,
errors: [{
messageId: 'missingInvocation',
data: { name: 'user' }
}],
output: `
const user = signal({ name: 'John' });
const displayName = user().name;
`
},
// Multiple signals, some missing invocation
{
code: `
const count = signal(0);
const doubled = computed(() => count() * 2);
const result = count + doubled;
`,
errors: [
{ messageId: 'missingInvocation', data: { name: 'count' } },
{ messageId: 'missingInvocation', data: { name: 'doubled' } }
]
}
]
});
console.log('All tests passed! 🎉');
Angular-Specific Unit Tests
Since we're in Angular land, let's also test our components properly:
// counter.component.spec.ts
import { TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { CounterComponent } from './counter.component';
describe('CounterComponent with Signals', () => {
let component: CounterComponent;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [CounterComponent]
});
component = TestBed.createComponent(CounterComponent).componentInstance;
});
it('should correctly access signal values', () => {
// This would fail ESLint if you forgot ()
expect(component.count()).toBe(0);
// Not this (ESLint would catch it!)
// expect(component.count).toBe(0); ❌
});
it('should update signal values', () => {
component.increment();
expect(component.count()).toBe(1);
});
it('should compute derived values', () => {
component.count.set(5);
expect(component.doubled()).toBe(10);
// ESLint saves you from:
// expect(component.doubled).toBe(10); ❌
});
});
Question for you: What edge cases would you add to these tests? Share your ideas! 💬
Integration Workflow: Making It Part of Your Dev Life
Now let's wire this up so it actually protects your codebase:
Step 1: Install and Configure
# Create custom rules directory
mkdir eslint-rules
cp require-signal-invocation.js eslint-rules/
# Update your .eslintrc.js
// .eslintrc.js
module.exports = {
// ... other config
rules: {
// ... other rules
'./eslint-rules/require-signal-invocation': 'error'
},
overrides: [
{
files: ['*.ts'],
rules: {
'./eslint-rules/require-signal-invocation': 'error'
}
}
]
};
Step 2: Enable Auto-Fix in VS Code
// .vscode/settings.json
{
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"eslint.validate": [
"javascript",
"typescript",
"html"
]
}
Step 3: Add to CI Pipeline
# .github/workflows/lint.yml
name: Lint
on: [push, pull_request]
jobs:
eslint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- run: npm ci
- run: npm run lint
- run: npm run lint:fix -- --dry-run
Step 4: Team Adoption Strategy
Here's how I rolled this out to my team without resistance:
- Demo the problem — Show a real bug it would've caught
-
Start as warning — Set to
'warn'
for the first week - Collect feedback — Ask: "Did it catch any bugs? Any false positives?"
- Graduate to error — Once everyone's comfortable
- Share the wins — "This rule caught 12 bugs last sprint!"
Have you introduced custom rules to your team? How did it go? Share your story! 👇
Real-World Code Examples: Before and After
Let's see this rule in action with actual Angular patterns:
Example 1: Component Template Binding
Before (Bug):
@Component({
template: `
<div>Count: {{ count }}</div>
<button (click)="increment()">+</button>
`
})
export class CounterComponent {
count = signal(0);
increment() {
// This would also trigger our rule!
this.count.set(this.count + 1); // ❌ NaN!
}
}
ESLint Warning:
⚠️ Signal "count" must be invoked with (). Did you forget ()?
Line 10: this.count + 1
^^^^^^^^^^
After (Fixed):
@Component({
template: `
<div>Count: {{ count() }}</div>
<button (click)="increment()">+</button>
`
})
export class CounterComponent {
count = signal(0);
increment() {
this.count.set(this.count() + 1); // ✅
}
}
Example 2: Computed Signals Chain
Before:
export class PricingComponent {
basePrice = signal(100);
taxRate = signal(0.08);
// Oops, forgot () on basePrice
taxAmount = computed(() => this.basePrice * this.taxRate());
// Double oops!
total = computed(() => this.basePrice + this.taxAmount);
}
After (Auto-Fixed):
export class PricingComponent {
basePrice = signal(100);
taxRate = signal(0.08);
taxAmount = computed(() => this.basePrice() * this.taxRate());
total = computed(() => this.basePrice() + this.taxAmount());
}
🎯 Bonus Tips: Level Up Your Signal Game
1. Create a Signal Snippet
Add this to your VS Code snippets for faster signal creation:
"Angular Signal": {
"prefix": "sig",
"body": [
"const ${1:name} = signal<${2:type}>(${3:initialValue});",
"$0"
]
}
2. Use Type Guards for Signal Detection
function isSignal<T>(value: any): value is Signal<T> {
return value && typeof value === 'function' && 'set' in value;
}
// Now TypeScript helps you remember ()
if (isSignal(myValue)) {
console.log(myValue()); // TypeScript knows this needs ()
}
3. Consider a Team Convention
// Suffix signals with $ for clarity
const count$ = signal(0);
const user$ = signal<User | null>(null);
// Now it's visually obvious what needs ()
console.log(count$()); // The $ reminds you!
Which bonus tip is your favorite? Or do you have your own? Let's discuss! 💬
Recap: What We've Accomplished
Let's quickly review what you've learned today:
✅ Identified the Problem: Silent signal invocation bugs that TypeScript misses
✅ Built the Solution: A custom ESLint rule with auto-fix capability
✅ Tested Thoroughly: Both the rule itself and our Angular components
✅ Integrated Smoothly: Into VS Code, CI/CD, and team workflows
✅ Explored Real Examples: Actual bugs this rule would catch
You now have a powerful safeguard against one of Angular's most common Signal pitfalls. No more mysterious [object Object]
in your templates!
Next Steps & Your Turn to Act! 🚀
Here's what you can do right now:
1. Implement Today
Copy the ESLint rule code above and add it to your project. Takes 5 minutes, saves hours of debugging.
2. Extend the Rule
What other signal-related mistakes could we catch? Some ideas:
- Detecting signals in
ngFor
without track functions - Warning about signals in non-reactive contexts
- Flagging computed signals with side effects
3. Share Your Experience
💬 I want to hear from you!
- Have you been bitten by the missing
()
bug? Tell me your debugging war story! - What other Angular gotchas deserve custom ESLint rules?
- Did you extend this rule? Share your improvements!
4. Spread the Knowledge
👏 Found this helpful? Hit that clap button (you can clap up to 50 times!) — it helps other developers discover this solution.
📬 Want more Angular tips like this? I share practical Angular insights every week. Follow me here on Medium and never miss a trick!
5. Take It Further
Consider open-sourcing your rule! The Angular community would love it. Tag me when you do — I'll be your first star ⭐
One Last Thing...
Remember: Great developers don't just write code — they build tools that make everyone's code better. This ESLint rule isn't just fixing your bugs; it's protecting your entire team.
So here's my challenge to you: What developer pain point will YOU solve this week?
Drop a comment with your idea — the best one gets a shoutout in my next article! 👇
P.S. If this article saved you even 10 minutes of debugging, you owe me a virtual coffee ☕ Hit that clap button and let's caffeinate more developers with knowledge!
P.P.S. Seriously though, try the rule. Then come back and tell me how many bugs it caught. I'm betting it'll surprise you! 😉
🚀 Follow Me for More Angular & Frontend Goodness:
I regularly share hands-on tutorials, clean code tips, scalable frontend architecture, and real-world problem-solving guides.
- 💼 LinkedIn — Let’s connect professionally
- 🎥 Threads — Short-form frontend insights
- 🐦 X (Twitter) — Developer banter + code snippets
- 👥 BlueSky — Stay up to date on frontend trends
- 🌟 GitHub Projects — Explore code in action
- 🌐 Website — Everything in one place
- 📚 Medium Blog — Long-form content and deep-dives
- 💬 Dev Blog — Free Long-form content and deep-dives
- ✉️ Substack — Weekly frontend stories & curated resources
- 🧩 Portfolio — Projects, talks, and recognitions
- ✍️ Hashnode — Developer blog posts & tech discussions
🎉 If you found this article valuable:
- Leave a 👏 Clap
- Drop a 💬 Comment
- Hit 🔔 Follow for more weekly frontend insights
Let’s build cleaner, faster, and smarter web apps — together.
Stay tuned for more Angular tips, patterns, and performance tricks! 🧪🧠🚀
Top comments (0)