import { sentiment } from 'textlens';

const result = sentiment('I love this product! It works great.');
console.log(result.label);       // "positive"
console.log(result.comparative); // 0.75
console.log(result.positive);    // ["love", "great"]

That's sentiment analysis running in Node.js. No API key, no network request, no Python. The entire lexicon ships with the package and runs locally.

This article covers how lexicon-based sentiment analysis works, when to use it, and how to build practical features with it: comment moderation, review scoring, and content tone checking.

Why sentiment analysis matters

Every app that handles user-generated text has an implicit opinion problem. Product reviews, support tickets, blog comments, social media posts—they all carry tone. Knowing that tone unlocks concrete features:

  • Comment moderation — Flag comments with strongly negative sentiment for review before publishing.
  • Review scoring — Aggregate sentiment across product reviews to surface the most positive and most critical feedback.
  • Content tone checking — Verify that marketing copy, documentation, or support responses maintain the right tone before publishing.
  • Customer feedback triage — Route angry support tickets to senior staff and positive ones to the testimonials page.

The problem with most sentiment solutions

Search for "sentiment analysis" and most results point to one of three approaches:

  1. Python ML libraries (NLTK, TextBlob, spaCy) — Requires a Python runtime. If your app is Node.js, you now maintain two runtimes or run a microservice.
  2. Cloud APIs (Google Natural Language, AWS Comprehend, Azure Text Analytics) — Pay per request. Add latency. Require API keys and network access. Break if your credit card expires.
  3. Large NLP models (Hugging Face transformers) — High accuracy, but models are hundreds of megabytes. Overkill for "is this comment positive or negative?"

For many use cases, you don't need 98% accuracy on sarcastic movie reviews. You need a fast, local, dependency-free way to score text sentiment in the same runtime your app already uses.

How AFINN-165 works

AFINN is a word list created by Finn Årup Nielsen, a researcher at the Technical University of Denmark. Each entry maps an English word to an integer score from -5 (most negative) to +5 (most positive).

The current version, AFINN-165, contains 2,477 words and phrases. Some examples:

WordScore
outstanding+5
love+3
good+3
nice+2
disappointing-2
terrible-3
awful-3
hate-3

The algorithm is straightforward:

  1. Tokenize the input into words.
  2. Look up each word in the AFINN lexicon.
  3. Sum the scores of all matched words to get the raw score.
  4. Divide by total word count to get the comparative score.

The comparative score normalizes for text length. A 500-word article with a raw score of +10 and a 5-word sentence with +10 have different intensities. The comparative score captures that: +0.02 vs +2.0.

Setting up

Install textlens, an open-source text analysis toolkit I built. Full disclosure: I'm the creator. It ships with the full AFINN-165 lexicon as part of a zero-dependency package that also handles readability scoring, keyword extraction, and more. We're using it here for its sentiment module.

npm install textlens

Import the sentiment function:

import { sentiment } from 'textlens';
// or CommonJS:
const { sentiment } = require('textlens');

Analyzing sentiment

Pass any string to sentiment(). It returns an object with the raw score, comparative score, label, confidence, and the positive and negative words it found.

import { sentiment } from 'textlens';

const positive = sentiment(
  'I love this product! It works great and the support team is amazing.'
);
console.log(positive);
// {
//   score: 0.31,
//   comparative: 0.92,
//   label: "positive",
//   confidence: 0.31,
//   positive: ["love", "great", "support", "amazing"],
//   negative: []
// }
const negative = sentiment(
  'This product is terrible. The quality is awful and customer service was unhelpful. Total waste of money.'
);
console.log(negative);
// {
//   score: -0.10,
//   comparative: -0.29,
//   label: "negative",
//   confidence: 0.24,
//   positive: ["quality"],
//   negative: ["terrible", "awful", "waste"]
// }
const neutral = sentiment(
  'The package arrived on Tuesday. It contained three items as listed on the invoice.'
);
console.log(neutral);
// {
//   score: 0,
//   comparative: 0,
//   label: "neutral",
//   confidence: 0,
//   positive: [],
//   negative: []
// }

Notice how "quality" appears as a positive word in the negative review. AFINN scores "quality" as +2 because the word has a positive connotation in most contexts. The overall score is still negative because "terrible", "awful", and "waste" outweigh it. This is a known limitation of lexicon-based approaches—they don't understand context.

Understanding the output fields

  • score — Normalized sum of all AFINN word scores divided by total words. Positive means positive sentiment, negative means negative.
  • comparative — Sum of matched AFINN scores divided by the count of matched words. Higher absolute values indicate stronger sentiment.
  • label"positive", "negative", or "neutral" based on the score.
  • confidence — Proportion of words that matched the lexicon. Higher values mean more of the text contributed to the score.
  • positive / negative — Arrays of the actual words that matched, useful for debugging and explanation.

Practical example: Scoring product reviews

Given an array of product reviews, score and rank them by sentiment:

import { sentiment } from 'textlens';

const reviews = [
  'Excellent build quality. Best purchase this year.',
  'Arrived broken. Support ghosted me for two weeks.',
  'It works fine. Nothing special but does the job.',
  'Absolutely love the design. Fast shipping too!',
  'Worst product I have ever bought. Completely useless.',
];

const scored = reviews
  .map((text) => ({
    text,
    ...sentiment(text),
  }))
  .sort((a, b) => b.score - a.score);

scored.forEach((r) => {
  const icon = r.label === 'positive' ? '+' : r.label === 'negative' ? '-' : '~';
  console.log(`[${icon}] ${r.score.toFixed(2)} | ${r.text}`);
});

Output:

[+] 0.33 | Absolutely love the design. Fast shipping too!
[+] 0.29 | Excellent build quality. Best purchase this year.
[~] 0.00 | It works fine. Nothing special but does the job.
[-] -0.09 | Arrived broken. Support ghosted me for two weeks.
[-] -0.30 | Worst product I have ever bought. Completely useless.

This gives you a ranked list from most positive to most negative. You could use this to:

  • Show the top 3 positive reviews on a product page.
  • Alert support teams when a review scores below -0.2.
  • Calculate an average sentiment score per product.

Practical example: Comment moderation filter

Build a function that flags comments for manual review based on sentiment:

import { sentiment } from 'textlens';

const NEGATIVE_THRESHOLD = -0.15;

function moderateComment(text) {
  const result = sentiment(text);

  if (result.score < NEGATIVE_THRESHOLD) {
    return {
      action: 'flag',
      reason: `Negative sentiment (${result.score.toFixed(2)}). Words: ${result.negative.join(', ')}`,
      result,
    };
  }

  return { action: 'approve', result };
}

// Test it:
const comments = [
  'Great article, thanks for sharing!',
  'This is the dumbest thing I have ever read. Trash.',
  'I disagree with point 3, here is why...',
  'Hate this garbage. Absolute scam.',
];

comments.forEach((text) => {
  const { action, reason } = moderateComment(text);
  console.log(`[${action.toUpperCase()}] ${text}`);
  if (reason) console.log(`  -> ${reason}`);
});

Output:

[APPROVE] Great article, thanks for sharing!
[FLAG] This is the dumbest thing I have ever read. Trash.
  -> Negative sentiment (-0.18). Words: dumbest, trash
[APPROVE] I disagree with point 3, here is why...
[FLAG] Hate this garbage. Absolute scam.
  -> Negative sentiment (-0.43). Words: hate, garbage, scam

The third comment ("I disagree...") passes through because disagreement without negative language isn't hostile. The filter catches tone, not opinion—which is the right behavior for moderation.

Practical example: Content tone checker

Check whether your documentation or marketing copy has the right tone before publishing:

import { sentiment } from 'textlens';

function checkTone(text, options = {}) {
  const { minScore = 0, maxScore = Infinity } = options;
  const result = sentiment(text);

  const issues = [];
  if (result.score < minScore) {
    issues.push(`Tone too negative (${result.score.toFixed(2)}, min: ${minScore})`);
  }
  if (result.score > maxScore) {
    issues.push(`Tone too positive (${result.score.toFixed(2)}, max: ${maxScore})`);
  }
  if (result.negative.length > 0) {
    issues.push(`Negative words found: ${result.negative.join(', ')}`);
  }

  return { pass: issues.length === 0, issues, result };
}

// Marketing copy should be positive:
const marketing = checkTone(
  'Our product helps teams collaborate faster and ship with confidence.',
  { minScore: 0 }
);
console.log('Marketing:', marketing.pass ? 'PASS' : 'FAIL');

// Error messages should be neutral, not negative:
const errorMsg = checkTone(
  'Failed to connect. Please check your terrible network settings.',
  { minScore: -0.05 }
);
console.log('Error msg:', errorMsg.pass ? 'PASS' : 'FAIL');
console.log('Issues:', errorMsg.issues);

This pattern integrates into CI pipelines or pre-publish hooks. Run it on documentation files, README changes, or user-facing copy to catch unintentional negative tone.

Using the CLI

textlens includes a command-line interface. Analyze a file and get sentiment as part of the JSON output:

npx textlens analyze article.md --format json

The output includes a sentiment field alongside readability scores, keywords, and statistics. Pipe it through jq to extract what you need:

npx textlens analyze article.md --format json | jq '.sentiment'

When lexicon-based sentiment falls short

AFINN-165 is a word-counting approach. It does not understand:

  • Sarcasm. "Oh great, another meeting" scores positive because "great" is +3. The actual sentiment is negative.
  • Negation. "not good" may score positive because "good" is +3 and "not" has no AFINN score. Some implementations handle basic negation; AFINN-165 does not.
  • Context. "The plot was sick" could be positive (slang) or negative (literal). The lexicon treats "sick" as negative (-2).
  • Domain-specific language. Medical text, legal text, and financial text use words with sentiment scores that don't match their domain meaning. "Positive test result" is not good news.
  • Multilingual text. AFINN-165 covers English only. Non-English words are ignored, which skews scores for mixed-language text.
  • Emoji and slang. The lexicon predates modern internet slang. It doesn't score emoji, abbreviations like "lol" or "smh", or newer slang.

These limitations matter. If you're building a system where wrong sentiment classifications have real consequences—like automatically deleting user comments or making business decisions based on review scores—a lexicon approach is not enough on its own.

When to use lexicon vs. ML approaches

Use caseRecommended approach
Flagging toxic comments for human reviewLexicon (AFINN). Fast, no infra, humans make final call.
Sorting feedback by toneLexicon. Ranking doesn't need perfect accuracy.
Content tone checking in CILexicon. Binary pass/fail on obvious negative language.
Sarcasm detectionML (transformer models). Lexicons can't detect sarcasm.
Fine-grained emotion classificationML. Distinguishing "angry" from "sad" from "frustrated" requires context.
Non-English textML or language-specific lexicons. AFINN covers English only.
High-stakes automated decisionsML with human oversight. Don't auto-delete based on a word list.

The lexicon approach works best as a filter, not a judge. Use it to surface content that needs human attention, to sort items by approximate tone, or as one signal among many in a scoring system.

Wrapping up

Lexicon-based sentiment analysis is not a replacement for machine learning. It is a pragmatic tool that covers a wide range of use cases without the overhead of ML infrastructure.

With AFINN-165, you get:

  • 2,477 scored English words, maintained by academic research.
  • Deterministic results—same input always produces the same output.
  • Sub-millisecond analysis, no network calls, no API keys.
  • Transparent scoring—you can see exactly which words contributed to the result.

If you need sentiment analysis in a Node.js or TypeScript project and your use case doesn't require context-aware NLP, a lexicon approach gets you there with zero infrastructure. textlens is one way to add it—a single npm install with no dependencies.

The full source is on GitHub.