Skip to content

This site describes solve-engine as it is on main: 2.43.0, which npm does not have yet. npm installs 2.40.0, so a page may show an answer that version does not give yet.

Locales

A locale is the set of conventions a place writes numbers and words in. The same characters mean different numbers in different places: 1.000 is one thousand to a German reader and one to an English one, and a German writes two and a half as 2,5, which is not a number at all in English. The engine keeps two locale settings, one for each direction, because reading what someone typed and writing an answer for them are separate questions.

  • The engine’s locale option chooses the language pack the engine reads with: its keywords (mal is times in German) and what a typed number means.
  • The formatter’s numberResult.decimalSeparatorLocale setting chooses how a result is written: the decimal mark, the digit grouping, the digits themselves, the names of weekdays, months and units, and where a currency symbol goes.
import { createEngine } from "solve-engine";
import { formatValue, DEFAULT_FORMATTING_SETTINGS } from "solve-engine/format";
const engine = createEngine({ locale: "de-DE" });
const value = engine.evaluateExpression("€1.250");
formatValue(value, {
...DEFAULT_FORMATTING_SETTINGS,
numberResult: { decimalSeparatorLocale: "de-DE" },
}); // "= 1.250,00 €"
engine.formatValue(value); // "= 1.250,00 €", from the engine's own locale

engine.formatValue takes the output tag from the engine’s locale option, so the two halves agree unless the host says otherwise; the default en, and a tag Intl has no number data for, write en-US. A worker runtime writes its results the same way.

A host usually passes the reader’s own tag to both, navigator.language in a browser, so that the engine reads the way the reader types and answers the way they read.

Three packs ship: English (en), German (de) and French (fr). Each decides which character groups thousands (the separator that makes 1,000,000 readable) and which marks the decimal. The table shows what each engine answers for the same typed characters, written out with the default output settings, which are American English. That is why the German engine’s two thousand five hundred shows as 2,500: the value is two thousand five hundred, and the formatter, not the reading, chose the comma.

Typedendefr
2.52.50refused2.50
$9.99$9.99refused$9.99
2.5002.502,5002.50
1.234.5671,234,5671,234,5671,234,567
1,0001,00011
1,5001,5001.501.50
1,234,5671,234,567refusedrefused
1.234,567refused1,234.57refused
12345.67812,345.68refused12,345.68
2.5 fps2.50 frames/srefused2.50 frames/s
2,5refused2.502.50
1,5000refused1.501.50
1.500,5refused1,500.50refused
1 500refusedrefused1,500
max(1,5; 2)refused22
max(1,5, 2)522
5/22.502.502.50

An English engine reads . as the decimal point and , as a thousands group. The digits after the point are not grouped, so 1.234,567, which is German for one thousand two hundred and thirty-four point five six seven, is refused rather than read as 1.234567.

A German engine reads . as a thousands group, so 2.500 is two thousand five hundred and 1.234.567 is over a million. A group is always three digits, so a dot followed by one, two, or four or more digits (2.5, 9.99, 3.14159) is not a German group: it is an English decimal typed into a German engine, and so is a first group longer than three digits (12345.678). The engine cannot use it either way without guessing, and guessing wrong makes $9.99 nine hundred and ninety-nine dollars, so it is refused with INVALID_NUMBER_LITERAL and a message that names the locale:

"2.5" is not a number in the de locale: "." groups thousands there, so it is followed by exactly three digits, as in 2.500 (two thousand five hundred).

A number read by a form of its own, such as a frame rate (2.5 fps), is read by the same rule, so a German 2.500 fps is two thousand five hundred frames a second, as the bare number is.

A French engine reads a dot as a decimal point, as English does. Its thousands group is a space: 1 500 is fifteen hundred, and so is 1 500 written with the narrow no-break space the engine itself writes between French groups, or with an ordinary no-break space. A group after the space is exactly three digits and the first group is one to three, so 1 50 and 12345 678 are two numbers side by side and refused, as 5 3 is. A German or English engine does not group with a space.

German and French both mark the decimal with a comma: two and a half is 2,5. In either engine, and in their regional tags (de-DE, fr-CA), a comma between two digits is that decimal comma, whatever follows it: 2,5, 1,50, 1,500 and 1,5000 are all read as a decimal, so 1,500 is one and a half, never fifteen hundred. It is read in money (€9,99), in a quantity (1,5 km), in a percentage (12,5%) and in a list, as a number is anywhere else.

A literal with a second mark after its decimal comma (1,500,000), or in French a dot decimal beside a comma one (1.234,567), has no reading, and is refused rather than cut short at the second mark. In German 1.234,567 is a thousands group and a decimal comma, and reads as one thousand two hundred and thirty-four and a bit.

A comma separates a function’s arguments in English: max(1, 2) is the larger of one and two. Where a comma is also the decimal mark, max(1,5, 2) could mean either the larger of one and a half and two, or the largest of three numbers. The engine never guesses between them. In a German or French engine:

  • A comma between two digits is always the decimal comma, inside a call or a bracket as much as outside one.
  • A ; separates arguments, as a spreadsheet in those languages does: max(1,5; 2) is 2.
  • A comma with a space after it still separates arguments, since a decimal comma never has one: max(1, 2) and max(1,5, 2) are both 2.
Typed under deReads asAnswer
max(1,5; 2)the larger of 1.5 and 22
max(1,5, 2)the larger of 1.5 and 22
max(1; 2)the larger of 1 and 22
max(1,2)the largest of one number, 1.21.20
max(1,5,2)one literal with two decimal commasrefused
rgb(255,0,0)one literal with two decimal commasrefused
rgb(255; 0; 0)three argumentsrgb(255, 0, 0)

A matrix keeps ; for its rows, so inside [...] the elements of a row are separated by a comma and a space, and the rows by ;: [1,5; 2,5] is a column of 1.5 and 2.5, and [1,5, 2,5] a row of them. A ; in a call inside a matrix separates that call’s arguments, and one in a matrix inside a call separates the matrix’s rows: each belongs to the bracket it is written in.

The boundary: the reading follows the engine’s locale only, never a guess made line by line. An English engine is unchanged, so there 1,500 is fifteen hundred, max(1,5, 2) is 5, and ; is not an argument separator. A tag with no pack of its own (es, it, nl) reads as English too, even though the engine writes that tag’s answers with a decimal comma (see writing results); an answer written for such a tag cannot yet be typed back into it. Outside a call a ; separates nothing, so 1,5 + 2,5; 3 is refused.

A language pack is a keyword table as well as a number format: the list of words the engine reads as operators, connectives and function names. A German or French pack adds its own words to the English ones rather than replacing them, so every English line reads the same under every pack. A German engine reads mal for times and von for of, and still reads times and of; a French engine reads fois and si ... alors ... sinon beside if ... then ... else. The words that come from the engine’s packages rather than from the keyword table, the unit names (km, lb), the currency codes, and phrases such as half of and 20% off, are read the same in every pack. Symbols are never translated: +, ^, % and ! mean the same everywhere.

Typedendefr
3 times 4121212
3 mal 4refused12refused
3 fois 4refusedrefused12
3 plus 4777
10 teilen 2refused5refused
10 diviser 2refusedrefused5
2 potenz 3refused8refused
2 puissance 3refusedrefused8
10 mod 3111
10% of 200202020
10% von 200refused20refused
half of 10555
20% off $50$40.00$40.00$40.00
5 km to miles3.11 miles3.11 miles3.11 miles
5 km in miles3.11 miles3.11 miles3.11 miles
konvertieren 5 km in milesrefused3.11 milesrefused
5 km en milesrefusedrefused3.11 miles
convertir 5 km en milesrefusedrefused3.11 miles
3pm Tokyo in Dubai10:00 AM10:00 AM10:00 AM
5 kg + 2 lb5.91 kg5.91 kg5.91 kg
true and falsefalsefalsefalse
vrai et fauxrefusedrefusedfalse
if 1 > 0 then 1 else 2111
si 1 > 0 alors 1 sinon 2refusedrefused1
sin(0)000
sqrt(16)444
round(7/2)444
floor(7/2)333
ceil(7/2)444
max(1, 2)222
wurzel(16)refused4refused
kubikwurzel(27)refused3refused
runden(7/2)refused4refused
aufrunden(7/2)refused4refused
abrunden(7/2)refused3refused
racine(16)refusedrefused4
arrondi(7/2)refusedrefused4
plancher(7/2)refusedrefused3
plafond(7/2)refusedrefused4
15.03.2024Friday, March 15, 2024Friday, March 15, 2024Friday, March 15, 2024

The dates and times words follow the same rule: today, now and next monday in every pack, heute, jetzt and naechste montag in German too, and aujourdhui (without its apostrophe, which a word cannot hold), maintenant and prochain lundi in French. They answer from the clock, so they are not in the table.

The pack’s own function names are the German wurzel (square root), kubikwurzel (cube root), runden, aufrunden and abrunden (round to the nearest, up and down), zufall (a random number), zeichen (the sign) and ganzzahl (the whole-number part), and the French racine, arrondi, plancher and plafond. Each runs the same function as its English name, so wurzel(16) and sqrt(16) are one call.

Two things are worth knowing before choosing a pack:

  • A pack’s words are keywords. A word a pack reads, in its own language or in English, cannot also be a variable name under that pack, as times cannot be one in English. Under fr that includes en, the conversion word, and racine, arrondi, plancher and plafond.
  • One word where the languages meet. French multiplier is the verb, so 3 multiplier 4 is 12 under fr, and the English as multiplier converter is not read there. It is the one word a pack spells the same as an English keyword with another meaning; the pack’s meaning is kept, since it is the word that pack’s reader writes.

The boundary: the packs are a keyword table, not a translation of every phrase. A package’s multi-word phrases (half of, compound interest on) and its unit names are English in every pack, and a pack adds no word for a phrase a package reads. A document can still name its own words for units, see custom units.

A tag names a language and, often, a region: de-DE is German as written in Germany, de-AT in Austria, en-GB British English. The engine has one pack per language, so a tag with no pack of its own reads as its language’s pack: de-DE, de-AT and de-CH read as de, and fr-FR and fr-CA as fr. The language is matched in any case, and de_DE, the spelling some operating systems use, reads as de too.

Typeddede-DEfr-FRen-GB
€1.250€1,250.00€1,250.00€1.25€1.25
1.5 + 1refusedrefused2.502.50
3 mal 41212refusedrefused

Any other code reads as English, including one with no pack at all (xx) and one that happens to name a built-in JavaScript property (toString, __proto__), which builds an ordinary English engine.

The region chooses nothing about reading, with one exception: Indian grouping, below.

In India a hundred thousand is one lakh and is written 1,00,000, and ten million is one crore, 1,00,00,000: the last three digits form a group, and every group before them is two digits. The engine reads this grouping in two places.

  • Beside a rupee marker, in any engine whose pack groups thousands with a comma: after the ₹ sign or before the code INR. See currency for the worked examples.
  • Everywhere, in an engine whose tag names India as its region: en-IN, hi-IN and the other Indian tags, which read as English otherwise.
Typedenen-IN
₹1,00,000₹100,000.00₹100,000.00
12,34,567 INR₹1,234,567.00₹1,234,567.00
12,34,567refused1,234,567

A bare 12,34,567 stays refused in an English engine: outside the Indian convention a group of two digits is not a group, and a refusal is safer than a guess. A German or French engine does not read Indian grouping even beside ₹, since it reads the comma as its decimal mark, so ₹1,00,000 there is a literal with two decimal commas and refused. In an English engine a comma inside a call or a bracket separates arguments and elements, so [1,00,000] is a list of three numbers.

numberResult.decimalSeparatorLocale takes any tag the runtime’s Intl knows, and the answer is written the way that tag writes numbers, dates and the words beside them: a unit’s long name in the tag’s language (Tage, jours), and a currency symbol in the place the tag gives it (after the amount under de-DE).

Tag3.5 days£1234.5₹1234567.892025-11-17
en-US (the default)3.50 days£1,234.50₹1,234,567.89Monday, November 17, 2025
de-DE3,50 Tage1.234,50 £1.234.567,89 ₹Montag, 17. November 2025
fr-FR3,50 jours1 234,50 £1 234 567,89 ₹lundi 17 novembre 2025
en-IN3.50 days£1,234.50₹12,34,567.89Monday, 17 November 2025
ar-EG٣٫٥٠ يوم١٬٢٣٤٫٥٠ £١٬٢٣٤٬٥٦٧٫٨٩ ₹الاثنين، ١٧ نوفمبر ٢٠٢٥
ar-EG-u-nu-latn3.50 يوم1,234.50 £1,234,567.89 ₹الاثنين، 17 نوفمبر 2025

A tag whose script has digits of its own (Arabic-Indic for ar-EG, Bengali for bn, Devanagari for mr) writes every digit in them, the fraction included. A host that wants Latin digits passes a tag that asks for them, as ar-EG-u-nu-latn does: the -u-nu-latn ending names the Latin numbering system.

A unit written as a symbol (km, kg) is written as it is in every language, and the currency symbol itself stays the engine’s ($ for every dollar), only its place moving. A host that writes answers back into a note keeps the engine’s English words with wordsResult: { spelling: "engine" }, since 3,50 Tage does not read back in; see formatting results.

Weekday and month names follow the tag wherever Intl has data for it, in a spelled-out date and in the answer to as weekday and as month. Where it has none (xx), the names come from the language pack instead, English for any code without one, so the answer does not depend on the machine the engine happens to run on. A time in another zone follows the tag’s clock the same way (19:00 under de for 10:00 London in Tokyo on 2026-03-10), and a time difference is written as Tokyo: London + 8 Stunden; see time-zone answers. A tag Intl cannot read at all (de_DE, which Intl spells de-DE) makes formatting a number throw Intl’s own RangeError, so a typo in the host’s configuration is seen rather than hidden behind different output.

  • Typed native digits are not read. An ar-EG engine writes ٣٫٥٠, but typing ٣٫٥ is not read as three and a half.
  • Only the German and French packs read a decimal comma. Every other tag reads as English, including those whose answers the engine writes with one.
  • The formatter is the other half. A pack decides what a typed line means, never how the answer is written: that is numberResult.decimalSeparatorLocale in the formatting settings, which engine.formatValue takes from the engine’s tag.
  • Pasted text is read by its own rules. numbers in and amounts in read a German engine’s text in German, and do not refuse what a typed line would: numbers in "preis 9.99" is 9 and 99 in a German engine. See pasted text.
  • The date-entry order a typed numeric date is read in is a separate setting, date.inputOrder; see date literals.