Skip to content

fix: handle negative pluralization correctly#276

Open
artahir-dev wants to merge 1 commit intovercel:mainfrom
artahir-dev:fix/negative-pluralization
Open

fix: handle negative pluralization correctly#276
artahir-dev wants to merge 1 commit intovercel:mainfrom
artahir-dev:fix/negative-pluralization

Conversation

@artahir-dev
Copy link

@artahir-dev artahir-dev commented Feb 6, 2026

Bug Fix: Incorrect Pluralization for Negative Values at Rounding Threshold

Overview

This PR addresses a grammar inconsistency in the format() function when used with the { long: true } option. Specifically, negative millisecond values at exact rounding thresholds (e.g., -1500ms) are incorrectly pluralized despite resolving to a magnitude of 1.

Affected Behavior:

ms(-1500, { long: true }); 
// Before: "-1 seconds" ❌  (incorrect: magnitude is 1, should be singular)
// After:  "-1 second"  ✅  (correct: matches English grammar rules)

Root Cause Analysis

The Problem

The existing pluralization logic in the plural() helper function determines singular vs. plural forms by checking the absolute input value against a threshold before rounding occurs:

// src/index.ts (line ~242, original implementation)
function plural(ms: number, msAbs: number, n: number, name: string): StringValue {
  const isPlural = msAbs >= n * 1.5;  // ⚠️ Checks raw absolute value
  return `${Math.round(ms / n)} ${name}${isPlural ? 's' : ''}` as StringValue;
}

Why This Fails

For negative values at the .5 threshold, JavaScript's Math.round() exhibits asymmetric behavior:

  • Positive: Math.round(1.5)2 (rounds away from zero)
  • Negative: Math.round(-1.5)-1 (rounds toward zero, per IEEE 754 round-half-up)

Execution trace for ms(-1500, { long: true }):

  1. msAbs = Math.abs(-1500) = 1500
  2. n = 1000 (seconds unit)
  3. isPlural = (1500 >= 1000 * 1.5)true
  4. rounded = Math.round(-1500 / 1000) = Math.round(-1.5)-1
  5. Result: "-1 seconds" ❌ (grammatically incorrect for magnitude of 1)

The threshold check (1500 >= 1500) passes because the raw absolute value meets the plural criterion, but the subsequent rounding reduces the magnitude to 1, which should be singular.


Proposed Solution

Implementation

Invert the logic flow: perform rounding first, then determine pluralization based on the rounded absolute value:

// src/index.ts (line ~236-244, proposed fix)
function plural(ms: number, msAbs: number, n: number, name: string): StringValue {
  const rounded = Math.round(ms / n);           // Step 1: Round first
  const isPlural = Math.abs(rounded) !== 1;     // Step 2: Check rounded magnitude
  return `${rounded} ${name}${isPlural ? 's' : ''}` as StringValue;
}

Why This Works

  • Singular forms occur only when |rounded| = 1 (e.g., -1 second, 1 minute)
  • Plural forms occur for all other values (e.g., 0 seconds, 2 hours, -3 days)
  • Consistent with English grammar rules where only magnitude 1 takes singular form

Correctness Guarantees

Condition Rounded Value Math.abs(rounded) !== 1 Form Example
ms = -1500 -1 false Singular "-1 second"
ms = -1501 -2 true Plural "-2 seconds"
ms = 1500 2 true Plural "2 seconds"
ms = 1000 1 false Singular "1 second"
ms = 0 0 true Plural "0 ms" (fallback)

Comprehensive Test Coverage

Edge Case Validation

Input Division Math.round() Magnitude Expected Output Status
-1000ms -1.0 -1 1 "-1 second" ✅ Already passing
-1499ms -1.499 -1 1 "-1 second" ✅ Already passing
-1500ms -1.5 -1 1 "-1 second" Was failing ❌
-1501ms -1.501 -2 2 "-2 seconds" ✅ Already passing
1499ms 1.499 1 1 "1 second" ✅ Already passing
1500ms 1.5 2 2 "2 seconds" ✅ Already passing
1501ms 1.501 2 2 "2 seconds" ✅ Already passing

Test Implementation

Added to src/format.test.ts:

it('should handle negative pluralization correctly', () => {
  expect(format(-1500, { long: true })).toBe('-1 second');
  expect(format(-1501, { long: true })).toBe('-2 seconds');
  expect(format(-1000, { long: true })).toBe('-1 second');
});

it('should handle positive pluralization correctly', () => {
  expect(format(1500, { long: true })).toBe('2 seconds');
  expect(format(1501, { long: true })).toBe('2 seconds');
  expect(format(1000, { long: true })).toBe('1 second');
});

Changes Summary

Modified Files

  1. src/index.ts (2 lines changed)

    • Function: plural() (lines ~236-244)
    • Impact: Fixes pluralization logic for negative threshold values
  2. src/format.test.ts (new test cases)

    • Added edge case coverage for negative and positive pluralization
    • Ensures regression prevention

Backward Compatibility

Fully backward compatible

  • All existing test suites pass without modification
  • Only affects the specific edge case of negative values at .5 thresholds
  • No breaking changes to public API

Technical Background: JavaScript Rounding Behavior

JavaScript's Math.round() implements round-half-up for positive numbers but round-toward-zero for negative numbers:

Math.round(0.5)   // → 1   (rounds up)
Math.round(1.5)   // → 2   (rounds up)
Math.round(-0.5)  // → -0  (rounds toward zero)
Math.round(-1.5)  // → -1  (rounds toward zero, NOT -2)

This asymmetry is defined in ECMAScript spec § 21.3.2.28 and follows IEEE 754's round-half-up mode. The original threshold-based logic was designed for positive values and did not account for this asymmetric behavior in negative inputs.


Verification Steps

Local Testing

# Install dependencies
npm install

# Run full test suite
npm test

Manual Verification

import { ms } from 'ms';

// Test the fix
console.log(ms(-1500, { long: true })); // Expected: "-1 second"
console.log(ms(-1501, { long: true })); // Expected: "-2 seconds"
console.log(ms(1500, { long: true }));  // Expected: "2 seconds"

Impact Assessment

Severity: Low (cosmetic grammar issue, no functional impact)
Likelihood: Low (requires exact threshold values)
User Impact: Improved UX for applications displaying negative time durations
Risk: Minimal (logic change is localized and well-tested)


References

  • ECMAScript Specification: Math.round
  • IEEE 754: Rounding modes
  • Related issue: Grammar consistency for negative values

Checklist:

  • Code follows project style guidelines
  • All existing tests pass
  • New test cases added for edge case coverage
  • No breaking changes to public API

@artahir-dev
Copy link
Author

image

@artahir-dev artahir-dev force-pushed the fix/negative-pluralization branch from d6b3c4e to 76b1a4a Compare February 6, 2026 09:45
@artahir-dev
Copy link
Author

@leerob

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant