Skip to content

Version 4.0 - #96

Merged
borgar merged 1 commit into
masterfrom
version-4
Oct 16, 2026
Merged

borgar merged 1 commit into
masterfrom
version-4

Conversation

@borgar

@borgar borgar commented Oct 16, 2026

Copy link
Copy Markdown
Owner

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; // 0

When 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.2024 in de).
  • 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.x

If 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, and ParseDataBool are all exported.
  • Locales describe their format conventions. LocaleData has a new format property, typed as LocalFormat. 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 what format() already accepted for its locale option: getLocale(0x0419).
  • addLocale() accepts a parsed LocaleToken as its second argument, not only a string tag.
  • format() options are typed as Partial<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 mode option, typed as ParseValueOptions (see item 11).
  • TOKEN_CURRENCY is 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.
  • LocaleData has new properties: currency, general, color, colors, and opcodes (used by delocalize()), and sysdate and systime (used by the system date and time codes). Every built-in locale defines them.
  • LocalFormat.monthFirst says whether a locale's long date puts the month before the day. It is worked out from sysdate when the locale is created, unless you set it.
  • getFormatInfo() reports minDecimals, the number of decimals the pattern always shows, next to maxDecimals.

Fixed

  • Patterns that collide with Object.prototype no longer crash. In 3.x the pattern cache inherited from Object.prototype, so format('toString', 1) threw TypeError: Cannot read properties of undefined. The cache is now a Map, and such patterns are handled like any other invalid pattern — throwing a SyntaxError, or emitting the invalid string under { throws: false }.
  • An empty locale tag is accepted. format('[$]0', 1) returns '1', as in Excel. In 3.x it threw Unknown 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.

@borgar
borgar merged commit 3f9bb76 into master Oct 16, 2026
3 checks passed
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