@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.
Maintainers
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
- Quick start
- Number formatting
- Percent formatting
- Currency formatting
- Compact notation
- File sizes
- Durations
- Ordinals
- Parsing
- Rounding & precision
- Precision-safe arithmetic
- Currency conversion
- The
NumberFormatterclass - The
Numstatic namespace - Locale & i18n providers
- TypeScript types
- Error handling
- Requirements
Install
npm install @num-kit/coreQuick 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"); // NaNParseOptions: 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); // 0RoundingMode 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 unchangedExchangeRateTable 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 aTypeErrorif givenNaNorInfinity— 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 aRangeErroron division by zero.convertCurrencythrows aRangeErroriffrom/toisn't in the rate table.parseNumbernever throws — it returnsNaNfor input it can't interpret, so you can always check withNumber.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
