DEV Community

Cover image for How Many Times Have You Missed () on Angular Signals? Let ESLint Catch It!
Rajat
Rajat

Posted on

How Many Times Have You Missed () on Angular Signals? Let ESLint Catch It!

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> { ... } 😱

Enter fullscreen mode Exit fullscreen mode

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

  1. No compile-time errors — TypeScript thinks you want the signal object
  2. Subtle runtime behavior — The app doesn't crash; it just acts weird
  3. Hard to trace — Especially in large codebases with complex data flow
  4. 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, '()');
            }
          });
        }
      }
    };
  }
};

Enter fullscreen mode Exit fullscreen mode

Breaking Down the Magic ✨

  1. Variable Tracking: We maintain a Set of all signal variable names
  2. AST Node Analysis: We check every identifier in your code
  3. Smart Detection: We skip legitimate uses (like signal.set())
  4. 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! 🎉');

Enter fullscreen mode Exit fullscreen mode

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); ❌
  });
});

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode
// .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'
      }
    }
  ]
};

Enter fullscreen mode Exit fullscreen mode

Step 2: Enable Auto-Fix in VS Code

// .vscode/settings.json
{
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  },
  "eslint.validate": [
    "javascript",
    "typescript",
    "html"
  ]
}

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

Step 4: Team Adoption Strategy

Here's how I rolled this out to my team without resistance:

  1. Demo the problem — Show a real bug it would've caught
  2. Start as warning — Set to 'warn' for the first week
  3. Collect feedback — Ask: "Did it catch any bugs? Any false positives?"
  4. Graduate to error — Once everyone's comfortable
  5. 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!
  }
}

Enter fullscreen mode Exit fullscreen mode

ESLint Warning:

⚠️ Signal "count" must be invoked with (). Did you forget ()?
   Line 10: this.count + 1
            ^^^^^^^^^^

Enter fullscreen mode Exit fullscreen mode

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); // ✅
  }
}

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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());
}

Enter fullscreen mode Exit fullscreen mode

🎯 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"
  ]
}

Enter fullscreen mode Exit fullscreen mode

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 ()
}

Enter fullscreen mode Exit fullscreen mode

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!

Enter fullscreen mode Exit fullscreen mode

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! 🧪🧠🚀

✨ Share Your Thoughts To 📣 Set Your Notification Preference

Top comments (0)