Repository navigation
v4.0.0
Migrating from numfmt 3.x to 4.0
Version 4.0 rewrites the library in TypeScript, and brings formatting and parsing closer to Excel. Most of the changes are to the interface: how absence is signalled, what token types and parse results look like, and how the package is published. Some output changes as well, mainly in rounding and locale data. Those are listed under Output changes.
Most projects need three changes: replace null checks with undefined checks, uppercase any hardcoded token type strings, and read parse results from value and formatId instead of v and z. If your code never inspects tokens, never parses input, and never compares a return value to null, you may need no code changes at all.
Breaking changes
1. Functions return undefined instead of null
Every function that signalled "no result" with null now returns undefined. This affects:
parseValue(),parseNumber(),parseDate(),parseTime(),parseBool()formatColor()dateToSerial()getLocale()
// 3.x
const color = formatColor(pattern, value);
if (color !== null) { use(color); }
// 4.0
const color = formatColor(pattern, value);
if (color !== undefined) { use(color); }If you want code that works with both, use a loose check, which catches null and undefined alike:
if (color != null) { use(color); }TypeScript catches this for you: color !== null no longer narrows the type, so using color reports that it is possibly undefined. Plain JavaScript does not catch it — a === null test now silently takes the wrong branch.
The parse functions changed further: see item 10.
2. Token type values are uppercase
tokenize() emits the same tokens in the same order, but every type value is now uppercase.
// 3.x
tokenize('0%'); // [{ type: 'zero', … }, { type: 'percent', … }]
// 4.0
tokenize('0%'); // [{ type: 'ZERO', … }, { type: 'PERCENT', … }]Replace any hardcoded comparison, or better, import the constant:
// 3.x
if (token.type === 'zero') { … }
// 4.0
import { TOKEN_ZERO } from 'numfmt';
if (token.type === TOKEN_ZERO) { … }3. tokenTypes is gone; import TOKEN_* constants instead
The tokenTypes dictionary has been removed. The individual constants are now exported from the package root, and the TokenType type is exported for TypeScript users.
// 3.x
import { tokenTypes } from 'numfmt';
if (token.type === tokenTypes.ZERO) { … }
// 4.0
import { TOKEN_ZERO } from 'numfmt';
if (token.type === TOKEN_ZERO) { … }The constant names map to the old dictionary keys directly: tokenTypes.AMPM becomes TOKEN_AMPM, tokenTypes.BREAK becomes TOKEN_BREAK, and so on for all 34 types. There is also one new type, TOKEN_CURRENCY (see item 12).
4. Condition tokens carry a string, not an array
This is the one change that fails silently in both JavaScript and TypeScript, so check for it explicitly. In 3.x a CONDITION token's value was a two-element array of operator and operand. It is now the raw condition string.
tokenize('[>=10]0')[0].value;
// 3.x: [ '>=', '10' ]
// 4.0: '>=10'Indexing still "works" and still returns a string, which is why nothing warns you:
const value = tokenize('[>=10]0')[0].value;
value[0]; // 3.x: '>=' 4.0: '>'
value[1]; // 3.x: '10' 4.0: '='If you split conditions apart yourself, parse the string instead:
const [ , operator, operand ] = /^(<[=>]?|>=?|=)\s*(-?[.\d]+)$/.exec(value);The upside is that Token.value is now uniformly a string for every token type.
5. The repeatChar option is now fillChar
defaultOptions in 3.x declared an option named repeatChar, but the formatter read opts.fillChar — so setting repeatChar never did anything. In 4.0 the option is named fillChar throughout and is documented.
// 3.x — silently ignored
format('*x0', 1, { repeatChar: '*' });
// 4.0
format('*x0', 1, { fillChar: '*' });If you passed fillChar in 3.x it kept working and still works. Only the dead repeatChar name is gone.
6. Package entry points and deep imports
The package now ships only built output. In 3.x the exports map resolved the default condition to ./lib/index.js, so raw source was published and deep imports into numfmt/lib/*.js resolved. In 4.0 only dist is published.
// 3.x — worked by accident
import { round } from 'numfmt/lib/round.js';
// 4.0 — import from the package root
import { round } from 'numfmt';Everything previously reachable by deep import is available from the root.
7. TypeScript: locale name arrays are fixed-length tuples
LocaleData now types its name lists as tuples, so the compiler enforces the correct count: MonthNames is exactly 12 strings and DayNames is exactly 7. Both types are exported.
// 3.x typings accepted any string[]
addLocale({ mmm: someStringArray }, 'xx');
// 4.0: Type 'string[]' is not assignable to type '[string, string, …]'.
// Target requires 12 element(s) but source may have fewer.If your data is a plain string[], assert it at the boundary:
import type { MonthNames } from 'numfmt';
addLocale({ mmm: someStringArray as MonthNames }, 'xx');ampm and bool are likewise [ string, string ]. This only affects TypeScript callers; the runtime is unchanged.
8. TypeScript: ParseData is now two types
The single ParseData typedef is replaced by ParseDataNum and ParseDataBool, matching what each parser can actually return:
| Function | 3.x | 4.0 |
|---|---|---|
parseNumber, parseDate, parseTime |
ParseData | null |
ParseDataNum | undefined |
parseBool |
ParseData | null |
ParseDataBool | undefined |
parseValue |
ParseData | null |
ParseDataNum | ParseDataBool | undefined |
ParseDataNum.value is a number and ParseDataBool.value is a boolean, so you no longer need to narrow value yourself. Both types carry an optional formatId, so reading result.formatId off a parseValue() result works without narrowing. Item 10 describes the renamed properties.
9. preferMDY is replaced by format.dateOrder
LocaleData.preferMDY has been removed. A locale's date order now lives in the new format property, alongside its other format conventions, as dateOrder:
dateOrder |
Order | 3.x equivalent |
|---|---|---|
0 |
month first (12/31/2025) |
preferMDY: true |
1 |
day first (31/12/2025) |
preferMDY: false |
2 |
year first (2025/12/31) |
preferMDY: false |
The parsers read numeric dates in the locale's order: month first when dateOrder is 0, day first when it is 1, and year first when it is 2. In 3.x, every locale without preferMDY read dates day first.
// 3.x
addLocale({ group: ',', preferMDY: true }, 'xx');
getLocale('xx').preferMDY; // true
// 4.0
addLocale({ group: ',', format: { dateOrder: 0 } }, 'xx');
getLocale('xx').format.dateOrder; // 0When you register a locale, set format.dateOrder explicitly rather than relying on the default.
In JavaScript, passing preferMDY is now silently ignored, and reading it returns undefined. In TypeScript, both are compile errors.
One built-in locale changes as a result: Filipino (fil) now parses dates month first, matching the Philippines' convention. In 3.x only en and en-US did.
10. Parse results are { value, formatId }
parseValue(), parseNumber(), parseDate(), parseTime(), and parseBool() return { value, formatId } instead of { v, z }. formatId is the number of one of Excel's built-in formats instead of a pattern string. To get the pattern, pass it to the new getFormatFromId(), which builds it for the locale you give.
// 3.x
parseValue('1,234.5'); // { v: 1234.5, z: '#,##0.00' }
// 4.0
parseValue('1,234.5'); // { value: 1234.5, formatId: 4 }
getFormatFromId(4); // '#,##0.00'Because the format is now an ID, a date parsed in one locale can be shown in that locale's own pattern:
parseValue('12/31/2024'); // { value: 45657, formatId: 14 }
getFormatFromId(14); // 'm/d/yyyy'
getFormatFromId(14, 'de'); // 'dd.mm.yyyy'Reading .v or .z now gives undefined without an error in JavaScript. TypeScript reports both as compile errors.
11. Parsing follows Excel and the locale by default
The parse functions take a new mode option. The default, mode: 0, accepts what Excel accepts when the same text is typed into a cell, using the locale's settings:
- Numeric dates use the locale's date order and separators.
/and-always work, and.works where the locale's date separator is.(31.12.2024inde). - The locale's own currency symbol is accepted on the side of the number where the locale puts it.
€is accepted on either side. - A few inputs that 3.x rejected now parse, such as the mixed fraction
1 1/2.
mode: 1 accepts a curated set of inputs, closer to what 3.x accepted. It allows some obvious forms that Excel rejects, such as dates with a weekday, and rejects some unusual ones Excel allows, such as sep10 or 1984/07-4.
parseValue('1 1/2'); // { value: 1.5, formatId: 12 }
parseValue('1 1/2', { mode: 1 }); // undefined, as in 3.xIf you show parsed input in a user interface, check the results against inputs your users actually type, and consider mode: 1.
12. Currency symbols are CURRENCY tokens
tokenize() now emits a CURRENCY token for currency symbols such as $, €, £, and ¥. In 3.x these were char tokens.
tokenize('$0');
// 3.x: [{ type: 'char', value: '$' }, { type: 'zero', … }]
// 4.0: [{ type: 'CURRENCY', value: '$' }, { type: 'ZERO', … }]If you look for currency symbols among CHAR tokens, check TOKEN_CURRENCY as well.
An empty locale tag, [$], is now a LOCALE token with an empty value. In 3.x it was a modifier token, and the pattern failed to parse.
Additions
- Full TypeScript types ship with the package.
FormatOptions,FormatInfo,FormatDateInfo,LocaleData,LocalFormat,LocaleToken,MonthNames,DayNames,Token,TokenType,ParseDataNum, andParseDataBoolare all exported. - Locales describe their format conventions.
LocaleDatahas a newformatproperty, typed asLocalFormat. It holds a locale's date order, date and time separators, padding of date and time parts, currency symbol position, negative number style, and spacing before the percent sign. Every built-in locale defines it. listLocales()is now exported from the package root.getLocale()accepts an Excel locale code as a number, matching whatformat()already accepted for itslocaleoption:getLocale(0x0419).addLocale()accepts a parsedLocaleTokenas its second argument, not only a string tag.format()options are typed asPartial<FormatOptions>, so misspelled or wrongly typed options are compile errors.getFormatFromId(id, locale)returns the pattern for one of Excel's built-in format IDs, built from the locale's conventions:getFormatFromId(14, 'de')returns'dd.mm.yyyy'. It emulates Windows Excel.delocalize(pattern, locale)converts a pattern written in a locale's own syntax to the canonical en-US form:delocalize('[Rot]#.##0,00', 'de')returns'[Red]#,##0.00'.- The parse functions take a
modeoption, typed asParseValueOptions(see item 11). TOKEN_CURRENCYis a new token type (see item 12).- System date and time codes are supported.
[$-F800]and[$-x-sysdate]format a date in the locale's long date format, and[$-F400]and[$-x-systime]format a time in its time format. - Locale tags with a calendar work, as in
[$-is-IS,6], alongside the numeric LCID form. LocaleDatahas new properties:currency,general,color,colors, andopcodes(used bydelocalize()), andsysdateandsystime(used by the system date and time codes). Every built-in locale defines them.LocalFormat.monthFirstsays whether a locale's long date puts the month before the day. It is worked out fromsysdatewhen the locale is created, unless you set it.getFormatInfo()reportsminDecimals, the number of decimals the pattern always shows, next tomaxDecimals.
Fixed
- Patterns that collide with
Object.prototypeno longer crash. In 3.x the pattern cache inherited fromObject.prototype, soformat('toString', 1)threwTypeError: Cannot read properties of undefined. The cache is now aMap, and such patterns are handled like any other invalid pattern — throwing aSyntaxError, or emitting theinvalidstring under{ throws: false }. - An empty locale tag is accepted.
format('[$]0', 1)returns'1', as in Excel. In 3.x it threwUnknown token modifier. - The pattern cache is capped at 1000 patterns. In 3.x every distinct pattern stayed in memory for the life of the process, so formatting many different patterns grew the heap without limit. The oldest pattern is now dropped when the cache is full.
Output changes
These changes make output match Excel. They need no code changes, but stored or compared output can differ from 3.x.
Rounding
Numbers round as Excel does: first to 15 significant digits, a tie going toward zero, and then to the pattern's last digit, a tie going away from zero. Digits are always written out in full.
| Pattern | Value | 3.x | 4.0 |
|---|---|---|---|
0.00 |
1.005 |
1.00 |
1.01 |
#,##0 |
2 ** 70 |
1,.18,059,162,071,741,13e,+21 |
1,180,591,620,717,410,000,000 |
0 |
1e21 |
1e+21 |
1000000000000000000000 |
##0.0E+0 |
999.96 |
1000.0E+0 |
1.0E+3 |
General |
4403928373.5 |
4403928373 |
4403928374 |
General |
19147467.115 |
19147467.11 |
19147467.12 |
round() follows the same rules, so round(1.005, 2) returns 1.01 instead of 1, and it rounds to at most 15 significant digits. Vulgar fractions such as # ?/? keep their 3.x rounding.
Times that round up to midnight
A time that rounds up to midnight moves the date to the next day, when the pattern shows a time. m/d/yy h:mm formats 45292.9999999 as 1/2/24 0:00, where 3.x showed 1/1/24 0:00. A pattern that shows only a date is unchanged.
System date and time codes
[$-F800] and [$-F400] now switch to the locale's long date and time formats, as in Excel. 3.x ignored the code and used the rest of the pattern.
format('[$-F800]dddd', 45356, { locale: 'de' });
// 3.x: 'Tuesday'
// 4.0: 'Dienstag, 5. März 2024'Locale data
The built-in locale data has been corrected against Windows Excel, so localized output differs for many locales. Most changes are to month and day names:
| Locale | Pattern | 3.x | 4.0 |
|---|---|---|---|
is |
mmm |
mar. |
mar |
de |
mmm |
März |
Mrz |
da |
ddd |
tir. |
ti |
es-MX |
#,##0.00 |
1.234,50 |
1,234.50 |
sv |
General (for true) |
TRUE |
SANT |
In all, short month names changed in 47 locales, short day names in 40, long month names in 23, AM/PM names in 15, and long day names in 9. The group separator changed in de-CH, it-CH, and pt, and a few other locales changed their decimal separator, percent sign, or TRUE/FALSE words.
What did not change
Pattern syntax is unchanged, so stored format strings need no migration. Apart from the changes listed above, format() results, formatColor() colors, getFormatDateInfo(), and the is*Format() predicates match 3.2.6.
Upgrade checklist
Run these against your codebase to find affected call sites:
# 1. null comparisons against numfmt return values
grep -rn "parseValue\|parseNumber\|parseDate\|parseTime\|parseBool\|formatColor\|dateToSerial\|getLocale" \
--include=*.{js,ts,jsx,tsx} . | grep "null"
# 2. lowercase token type strings
grep -rn "type === '\(general\|hash\|zero\|qmark\|slash\|group\|scale\|comma\|break\|text\|plus\|minus\|point\|space\|percent\|digit\|calendar\|error\|datetime\|duration\|condition\|dbnum\|natnum\|locale\|color\|modifier\|ampm\|escaped\|string\|skip\|exp\|fill\|paren\|char\)'" .
# 3. the removed dictionary, option, and locale property
grep -rn "tokenTypes\|repeatChar\|preferMDY" .
# 4. deep imports
grep -rn "from ['\"]numfmt/" .
# 5. parse calls, whose results now use value and formatId instead of v and z
grep -rn "parseValue\|parseNumber\|parseDate\|parseTime\|parseBool" \
--include=*.{js,ts,jsx,tsx} .
# 6. system date and time codes, which now change the output
grep -rn "F800\|F400\|x-sysdate\|x-systime" .Then read through any code that indexes into a token's value, since the condition-token change (item 4 above) produces no error. Check code that looks for currency symbols among CHAR tokens, since they are now CURRENCY tokens (item 12). And if you store or compare formatted output, expect differences from the rounding and locale data changes under Output changes.