npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@num-kit/core

v0.1.1

Published

Lightweight, framework-agnostic TypeScript toolkit for number formatting, parsing, currency conversion, rounding, and human-readable output, with injectable i18n providers. Zero dependencies.

Readme

@num-kit/core

A lightweight, framework-agnostic TypeScript toolkit for number formatting, parsing, currency conversion, precision-safe arithmetic, rounding, and human-readable output — with fully injectable i18n/localization providers. Zero runtime dependencies, ESM-only, 100% type-safe.

Install

npm install @num-kit/core

Quick start

Two ways to use the package — pick whichever fits your project:

Tree-shakable named exports (recommended for libraries and size-sensitive bundles — only the functions you import end up in your bundle):

import { formatCurrency, round, formatCompact } from "@num-kit/core";

formatCurrency(1234.5, "USD"); // "$1,234.50"
round(1.005, 2); // 1.01
formatCompact(1_500_000); // "1.5M"

The Num static namespace (convenient for apps, scripts, and prototyping — one discoverable object, same runtime cost as the named exports since it's a thin re-export):

import { Num } from "@num-kit/core";

Num.currency(1234.5, "USD"); // "$1,234.50"
Num.round(1.005, 2); // 1.01
Num.compact(1_500_000); // "1.5M"

Every formatting function accepts an optional locale in its options and, unless noted otherwise, falls back to the global default locale (en-US out of the box — see Locale & i18n providers to change it).

Number formatting

formatNumber(value, options?, provider?) — locale-aware grouping and fraction-digit formatting for plain decimal numbers.

import { formatNumber } from "@num-kit/core";

formatNumber(1234.5); // "1,234.5"
formatNumber(1234.5, { locale: "de-DE" }); // "1.234,5"
formatNumber(0.1, { minimumFractionDigits: 2 }); // "0.10"
formatNumber(1234.5, { useGrouping: false }); // "1234.5"
formatNumber(5, { signDisplay: "always" }); // "+5"

BaseFormatOptions (shared by nearly every formatter — see TypeScript types):

| Option | Type | Description | | --- | --- | --- | | locale | string | BCP 47 locale tag, e.g. "de-DE". Defaults to the active provider's locale. | | minimumFractionDigits / maximumFractionDigits | number | Fraction digit bounds. | | minimumIntegerDigits | number | Zero-pad the integer part. | | useGrouping | boolean | Thousands separators. Defaults to true. | | signDisplay | "auto" \| "always" \| "never" \| "exceptZero" \| "negative" | Mirrors Intl.NumberFormat's option. | | roundingMode | "round" \| "floor" \| "ceil" \| "trunc" \| "half-even" | How to round when trimming to the configured fraction digits. |

Throws a TypeError if value is NaN or Infinity — see Error handling.

Percent formatting

formatPercent(value, options?, provider?) — by default treats value as a fraction (matching Intl.NumberFormat's style: 'percent'); pass alreadyPercent: true if your value is already on a 0–100 scale.

import { formatPercent } from "@num-kit/core";

formatPercent(0.5); // "50%"
formatPercent(0.5, { minimumFractionDigits: 1 }); // "50.0%"
formatPercent(50, { alreadyPercent: true }); // "50%"

PercentFormatOptions extends BaseFormatOptions with alreadyPercent?: boolean.

Currency formatting

formatCurrency(value, currencyCode, options?, provider?) — locale-aware symbol placement, grouping, and per-currency fraction digits (2 for USD, 0 for JPY, etc).

import { formatCurrency, getCurrencySymbol } from "@num-kit/core";

formatCurrency(1234.5, "USD"); // "$1,234.50"
formatCurrency(1234.5, "EUR", { locale: "de-DE" }); // "1.234,50 €"
formatCurrency(1234.5, "USD", { currencyDisplay: "code" }); // "USD 1,234.50"
formatCurrency(99, "JPY"); // "¥99" (no decimals, per ISO 4217)

getCurrencySymbol("USD"); // "$"
getCurrencySymbol("EUR", "de-DE"); // "€"

currencyCode is an ISO 4217 code ("USD", "EUR", "MAD", ...). CurrencyFormatOptions extends BaseFormatOptions with currencyDisplay?: "symbol" | "narrowSymbol" | "code" | "name".

Compact notation

formatCompact(value, options?, provider?) — short-scale abbreviations (1K/1.5M/2B/...) with full control over the unit table, precision, and spacing. Unlike Intl.NumberFormat's built-in notation: 'compact', behavior is identical across every JS engine (the native option's thresholds and abbreviations vary by runtime).

import { formatCompact } from "@num-kit/core";

formatCompact(950); // "950"  (below the smallest unit — plain number)
formatCompact(1000); // "1K"
formatCompact(1500); // "1.5K"
formatCompact(1500, { precision: 0 }); // "2K"
formatCompact(999_950, { precision: 1 }); // "1M"  (rounding crosses the boundary)
formatCompact(2_000_000, { spaced: true }); // "2 M"
formatCompact(-1500); // "-1.5K"  (sign preserved)

Custom unit tables, including the built-in long-scale variant:

import { formatCompact, LONG_SCALE_COMPACT_UNITS } from "@num-kit/core";

formatCompact(2_500_000_000, { units: LONG_SCALE_COMPACT_UNITS }); // "2.5Bn"

formatCompact(42_000, {
  units: [
    { value: 1000, symbol: " thousand" },
    { value: 1, symbol: "" }
  ]
});
// "42 thousand"

CompactFormatOptions extends BaseFormatOptions:

| Option | Type | Default | Description | | --- | --- | --- | --- | | units | readonly CompactUnit[] | DEFAULT_COMPACT_UNITS (K/M/B/T/Qa/Qi) | Magnitude/symbol table, descending. | | precision | number | 1 | Decimal places retained after scaling. | | trimTrailingZeros | boolean | true | Drop trailing .0. | | spaced | boolean | false | Insert a space before the unit symbol. |

File sizes

formatFileSize(bytes, options?, provider?) — human-readable byte counts, decimal (1000-based, kB/MB/...) by default, or binary (1024-based, KiB/MiB/...) with binary: true.

import { formatFileSize } from "@num-kit/core";

formatFileSize(0); // "0 B"
formatFileSize(1536); // "1.5 kB"
formatFileSize(1536, { binary: true }); // "1.5 KiB"
formatFileSize(123_456_789, { precision: 2 }); // "123.46 MB"
formatFileSize(-2048); // "-2 kB"

FileSizeOptions: locale, binary?: boolean, precision?: number (default 1), unitSeparator?: string (default " "), and units?: readonly string[] to fully override the label table.

Durations

formatDuration(milliseconds, options?) — breaks a duration down into its largest applicable units and renders up to maxUnits of them (default 2).

import { formatDuration } from "@num-kit/core";

formatDuration(90_000); // "1 min 30 sec"
formatDuration(90_000, { maxUnits: 1 }); // "1 min"
formatDuration(3_661_000, { style: "long" }); // "1 hour 1 minute"
formatDuration(3_661_000, { style: "narrow" }); // "1h1m"
formatDuration(500); // "500 ms"
formatDuration(-90_000); // "-1 min 30 sec"
formatDuration(90_061_000); // "1 day 1 hr"

Custom units, order, and labels:

formatDuration(3_661_000, { units: ["h", "m", "s"], maxUnits: 3 }); // "1 hr 1 min 1 sec"

formatDuration(90_000, {
  labels: { m: { long: ["minute", "minutes"], short: "мин", narrow: "м" } }
});
// "1 мин 30 sec"

DurationFormatOptions: locale, units?: readonly DurationUnit[] (from "y" | "mo" | "w" | "d" | "h" | "m" | "s" | "ms"), maxUnits?: number (default 2), style?: "long" | "short" | "narrow" (default "short"), separator?: string (defaults to " ", or "" for narrow style), and labels? to override individual unit labels.

Ordinals

formatOrdinal(value, options?) — appends the correct English ordinal suffix by default, using Intl.PluralRules to pick the right grammatical category.

import { formatOrdinal } from "@num-kit/core";

formatOrdinal(1); // "1st"
formatOrdinal(2); // "2nd"
formatOrdinal(3); // "3rd"
formatOrdinal(11); // "11th"
formatOrdinal(22); // "22nd"

The built-in suffixes are English-only; pass suffixes to support other languages (the locale option only affects which plural category Intl.PluralRules picks, not the suffix text itself):

formatOrdinal(1, {
  locale: "fr-FR",
  suffixes: { one: "er", other: "e" }
});
// "1er"

Parsing

parseNumber(input, options?, provider?) — turns human-entered, localized strings back into numbers. Handles parenthesized negatives, percent signs, currency symbols, and compact suffixes, in that order, before falling back to locale-aware separator normalization. Returns NaN for unparsable input (never throws).

import { parseNumber } from "@num-kit/core";

parseNumber("1,234.56"); // 1234.56
parseNumber("1.234,56", { locale: "de-DE" }); // 1234.56
parseNumber("(1,234.50)"); // -1234.5  (accounting-style negative)
parseNumber("45%"); // 0.45
parseNumber("$1,200", { currencySymbols: ["$"] }); // 1200
parseNumber("1.5M"); // 1500000  (compact suffix)
parseNumber("1,200", { allowCompact: false }); // 1200
parseNumber("not a number"); // NaN

ParseOptions: locale, allowPercent?: boolean (default true), allowCompact?: boolean (default true), compactUnits?: readonly CompactUnit[], and currencySymbols?: readonly string[] — symbols/codes to strip before parsing (e.g. ["$", "USD", "€"]).

normalizeNumberString(input, options?, provider?) converts a localized string into a plain ASCII numeric string (no grouping, . as decimal point) without turning it into a number — useful for very large integers where you don't want to risk floating-point precision loss:

import { normalizeNumberString } from "@num-kit/core";

normalizeNumberString("1.234,56", { locale: "de-DE" }); // "1234.56"
normalizeNumberString("1,234.56"); // "1234.56"

parseCompactNumber(input, units?) is a pure, locale-independent helper for compact-suffixed strings specifically:

import { parseCompactNumber } from "@num-kit/core";

parseCompactNumber("1.5K"); // 1500
parseCompactNumber("2M"); // 2000000
parseCompactNumber("950"); // 950 (no suffix)

Rounding & precision

Standard floating-point math produces artefacts like 1.005 * 100 === 100.49999999999999. round/floor/ceil/trunc sidestep this with a string-based decimal shift.

import { round, floor, ceil, trunc, toPrecision, decimalPlaces } from "@num-kit/core";

round(1.005, 2); // 1.01  (not 1.00, unlike naive value * 100 / 100)
round(2.5); // 3     (half away from zero, the default mode)
round(4.5, 0, "half-even"); // 4  (banker's rounding)
round(1234, -2); // 1200  (negative precision rounds to tens/hundreds/...)
round(1.999, 2, "floor"); // 1.99

floor(1.99, 1); // 1.9
ceil(1.91, 1); // 2
trunc(-1.99, 1); // -1.9

toPrecision(123456, 2); // 120000  (2 significant figures, not decimal places)
toPrecision(0.012345, 2); // 0.012

decimalPlaces(1.5); // 1
decimalPlaces(1); // 0

RoundingMode is "round" | "floor" | "ceil" | "trunc" | "half-even". Non-finite input (NaN/Infinity) is returned untouched by round/floor/ceil/trunc.

Precision-safe arithmetic

Plain JS arithmetic on decimals is famously imprecise (0.1 + 0.2 === 0.30000000000000004). These helpers scale operands to integers first, so everyday decimal math (money, in particular) behaves the way you'd expect.

import { add, subtract, multiply, divide, sum, average, min, max, median, percentageOf, percentageChange } from "@num-kit/core";

add(0.1, 0.2); // 0.3        (not 0.30000000000000004)
subtract(0.3, 0.1); // 0.2   (not 0.19999999999999998)
multiply(0.1, 0.2); // 0.02  (not 0.020000000000000004)
divide(0.3, 0.1); // 3       (not 2.9999999999999996)
divide(1, 0); // throws RangeError

sum([0.1, 0.2, 0.3]); // 0.6
average([1, 2, 3]); // 2
min([3, 1, 2]); // 1
max([3, 1, 2]); // 3
median([1, 2, 3, 4]); // 2.5  (average of the two middle values)

percentageOf(25, 200); // 0.125   (25 is 12.5% of 200)
percentageChange(100, 110); // 0.1    (+10%)
percentageChange(100, 50); // -0.5   (-50%)

sum/average/min/max/median return 0 (sum) or NaN (the rest) for an empty array, rather than throwing.

Real-world example — computing a cart total:

import { sum, multiply, formatCurrency } from "@num-kit/core";

const lineItems = [
  { price: 19.99, qty: 3 },
  { price: 4.5, qty: 2 }
];

const total = sum(lineItems.map((item) => multiply(item.price, item.qty)));
formatCurrency(total, "USD"); // "$68.97"

Currency conversion

convertCurrency(amount, from, to, table) converts using a rate table quoted relative to a single base currency.

import { convertCurrency } from "@num-kit/core";

const table = { base: "USD", rates: { USD: 1, EUR: 0.92, MAD: 9.95 } };

convertCurrency(100, "USD", "EUR", table); // 92
convertCurrency(100, "EUR", "MAD", table); // 1081.5217391304348
convertCurrency(100, "USD", "XYZ", table); // throws RangeError (unknown currency)

For repeated conversions, createCurrencyConverter(table) returns a reusable, bindable converter with a convert-then-format shortcut:

import { createCurrencyConverter } from "@num-kit/core";

const fx = createCurrencyConverter({ base: "USD", rates: { USD: 1, EUR: 0.92, MAD: 9.95 } });

fx.convert(100, "USD", "EUR"); // 92
fx.format(100, "USD", "EUR"); // "€92.00"
fx.format(100, "USD", "MAD", { locale: "fr-FR" }); // "995,00 MAD"

// Refresh live rates without losing the reference elsewhere:
const refreshed = fx.withRates({ base: "USD", rates: { USD: 1, EUR: 0.9 } });
refreshed.convert(100, "USD", "EUR"); // 90
fx.convert(100, "USD", "EUR"); // still 92 — the original converter is unchanged

ExchangeRateTable is { base: string; rates: Record<string, number> }, where each rates[code] is "how many units of code equal one unit of base".

The NumberFormatter class

A reusable, locale-bound formatter — handy when repeatedly formatting for the same locale (a request, a user session, a component) without threading { locale } through every call.

import { NumberFormatter } from "@num-kit/core";

const fr = new NumberFormatter("fr-FR");

fr.number(1234.5); // "1 234,5"
fr.currency(1234.5, "EUR"); // "1 234,50 €"
fr.percent(0.5); // "50 %"
fr.compact(1_500_000); // "1,5M"
fr.fileSize(1536); // "1,5 kB"
fr.duration(90_000); // "1 min 30 sec"
fr.ordinal(2); // "2th"  (English suffixes by default — see Ordinals above)
fr.parse("1 234,56"); // 1234.56
fr.currencySymbol("EUR"); // "€"

// Per-call overrides still work and take precedence over the instance locale:
fr.number(1234.5, { locale: "en-US" }); // "1,234.5"

// Derive a new formatter bound to a different locale:
const en = fr.withLocaleTag("en-US");
en.number(1234.5); // "1,234.5"

Every instance method mirrors its standalone function counterpart 1:1 (.number → formatNumber, .currency → formatCurrency, .percent → formatPercent, .compact → formatCompact, .fileSize → formatFileSize, .duration → formatDuration, .ordinal → formatOrdinal, .parse → parseNumber, .normalize → normalizeNumberString, .currencySymbol → getCurrencySymbol).

The constructor also accepts a custom provider directly (new NumberFormatter(myProvider)) — see Locale & i18n providers.

The Num static namespace

Every function above, namespaced under one object — convenient for scripts and application code where a single discoverable entry point beats remembering two dozen import names. It's a thin re-export, so it costs nothing extra at runtime over the named exports.

import { Num } from "@num-kit/core";

// Formatting
Num.format(1234.5); // "1,234.5"          (alias: Num.number)
Num.percent(0.5); // "50%"
Num.currency(1234.5, "USD"); // "$1,234.50"
Num.currencySymbol("USD"); // "$"
Num.compact(1_500_000); // "1.5M"
Num.fileSize(1536); // "1.5 kB"
Num.duration(90_000); // "1 min 30 sec"
Num.ordinal(2); // "2nd"

// Parsing
Num.parse("1,234.56"); // 1234.56
Num.normalize("1,234.56"); // "1234.56"
Num.parseCompact("1.5K"); // 1500

// Rounding & precision
Num.round(1.005, 2); // 1.01
Num.floor(1.99, 1); // 1.9
Num.ceil(1.91, 1); // 2
Num.trunc(-1.99, 1); // -1.9
Num.toPrecision(123456, 2); // 120000
Num.decimalPlaces(1.5); // 1

// Precision-safe arithmetic
Num.add(0.1, 0.2); // 0.3
Num.subtract(0.3, 0.1); // 0.2
Num.multiply(0.1, 0.2); // 0.02
Num.divide(0.3, 0.1); // 3
Num.sum([0.1, 0.2, 0.3]); // 0.6
Num.average([1, 2, 3]); // 2
Num.min([3, 1, 2]); // 1
Num.max([3, 1, 2]); // 3
Num.median([1, 2, 3, 4]); // 2.5
Num.percentageOf(25, 200); // 0.125
Num.percentageChange(100, 110); // 0.1

// Currency conversion
Num.convertCurrency(100, "USD", "EUR", { base: "USD", rates: { USD: 1, EUR: 0.92 } }); // 92
const fx = Num.currencyConverter({ base: "USD", rates: { USD: 1, EUR: 0.92 } });
fx.format(100, "USD", "EUR"); // "€92.00"

// Locale
Num.setLocale("de-DE"); // switch every subsequent Num.* call at once
Num.format(1234.5); // "1.234,5"
Num.getProvider().locale; // "de-DE"

// A bound formatter, same as `new NumberFormatter(...)`
const de = Num.formatter("de-DE");
de.number(1234.5); // "1.234,5"

Locale & i18n providers

Every formatter accepts an optional provider argument and/or reads the global default provider. Out of the box, numkit uses IntlNumberFormatProvider, backed entirely by the native Intl APIs (Node.js, browsers, Deno).

Switch the global default locale:

import { Num } from "@num-kit/core";

Num.setLocale("de-DE");
Num.format(1234.5); // "1.234,5"
import { setDefaultProvider, IntlNumberFormatProvider } from "@num-kit/core";

setDefaultProvider(new IntlNumberFormatProvider("ja-JP"));

Bring your own provider — for example to source locale data from an existing i18n library (react-intl, i18next, a translation CMS) instead of Intl, or to support runtimes without full Intl coverage. Implement the NumberFormatProvider interface:

import { setDefaultProvider, type NumberFormatProvider } from "@num-kit/core";

const myProvider: NumberFormatProvider = {
  locale: "fr-FR",
  format(value, options) {
    return myI18nLib.formatNumber(value, options);
  },
  getSymbols() {
    return { decimal: ",", group: " ", minusSign: "-", plusSign: "+", percentSign: "%" };
  },
  // Optional:
  getCurrencySymbol(currencyCode) {
    return myI18nLib.currencySymbol(currencyCode);
  },
  withLocale(locale) {
    return { ...myProvider, locale };
  }
};

setDefaultProvider(myProvider);

Only locale, format, and getSymbols are required — parseNumber, getCurrencySymbol, and withLocale are optional and numkit falls back to sensible defaults when they're omitted.

A provider can also be passed per-call instead of registered globally, to any function that accepts one, or to new NumberFormatter(provider).

TypeScript types

All option and data shapes are exported for reuse in your own function signatures:

import type {
  RoundingMode,
  NotationType,
  SignDisplay,
  BaseFormatOptions,
  PercentFormatOptions,
  CurrencyDisplay,
  CurrencyFormatOptions,
  CompactUnit,
  CompactFormatOptions,
  ParseOptions,
  LocaleSymbols,
  ExchangeRateTable,
  FileSizeOptions,
  DurationUnit,
  DurationFormatOptions,
  DurationLabel,
  OrdinalFormatOptions,
  NumberFormatProvider,
  ProviderFormatOptions,
  CurrencyConverter
} from "@num-kit/core";

Constants used as defaults internally are also exported, so you can extend or reference them rather than redefining your own:

import {
  DEFAULT_COMPACT_UNITS, // K/M/B/T/Qa/Qi thresholds
  LONG_SCALE_COMPACT_UNITS, // long-scale variant (Bn/Tr)
  DECIMAL_BYTE_UNITS, // B/kB/MB/...
  BINARY_BYTE_UNITS, // B/KiB/MiB/...
  DEFAULT_DURATION_UNITS, // ["y","mo","w","d","h","m","s","ms"]
  DEFAULT_DURATION_LABELS, // English long/short/narrow labels per unit
  DEFAULT_ORDINAL_SUFFIXES, // English st/nd/rd/th by Intl.PluralRules category
  DURATION_UNIT_MS // milliseconds per duration unit
} from "@num-kit/core";

Error handling

  • Formatting functions (formatNumber, formatPercent, formatCurrency, formatCompact, formatFileSize, formatDuration, formatOrdinal) throw a TypeError if given NaN or Infinity — formatting a non-finite value is almost always a bug at the call site, so numkit fails loudly instead of returning "NaN" or "∞" silently.
  • divide (and anything built on it — average, percentageOf, percentageChange, currency conversion) throws a RangeError on division by zero.
  • convertCurrency throws a RangeError if from/to isn't in the rate table.
  • parseNumber never throws — it returns NaN for input it can't interpret, so you can always check with Number.isNaN(result).
import { formatNumber, parseNumber } from "@num-kit/core";

try {
  formatNumber(NaN);
} catch (err) {
  // TypeError: numkit: formatNumber() expected a finite number, received NaN
}

const value = parseNumber(userInput);
if (Number.isNaN(value)) {
  // handle invalid input — no try/catch needed
}

Requirements

Node.js >=18.0.0. Ships as ESM only (no CommonJS build) — use import, not require. Depends only on the native Intl APIs; no runtime dependencies.

License

MIT