Osy#betaa language · its runtime Osyrin · a hosted platform
Why Osy#Built for agentsAgents as declarationsWorkflows that waitRuns exactly onceSecure by defaultNothing to mockThe editor is the compilerUI in the languageDocuments are dataOne program

Reference / Stdlib

Culture formatting — ToString(format, culture)

string value.ToString(format, culture) · app.DefaultCulture = "sv-SE"

Format numbers, currency, percentages and dates for a declared culture — `total.ToString("C", "sv-SE")` → `1 234,56 kr`. A per-call culture overrides; `app.DefaultCulture` sets an app-wide default. Supported formats run in the browser, byte-identical to the server; everything else runs server-side.

stable5 examples compiled by CIculturelocaleformatcurrency

Summary#

Format a number or date for a declared culture, spelled exactly as C#'s IFormattable.ToString(format, provider) — the provider is a BCP-47 culture token ("sv-SE", "de-DE"), not an ambient setting:

var price = total.ToString("C", "sv-SE");     // "1 234,56 kr"  — Swedish krona, space grouping, comma decimal
var pct   = rate.ToString("P1", "en-US");     // "12.5 %"
var when  = order.CreatedAt.ToString("D", "de-DE");   // "Freitag, 5. Januar 2024"

The culture must be declared (a cultures { } set; the platform ships a default pack, so sv-SE/de-DE/… work with no ceremony). A per-call culture is the override; app.DefaultCulture sets the app-wide default so a culture-less total.ToString("C") still formats for it.

Signature#

string  value.ToString(string format)                  // the app-default culture (else invariant)
string  value.ToString(string format, string culture)  // an explicit culture — the per-call override
// declared once, app-wide:
app.DefaultCulture = "sv-SE";
// per-viewer (on the principal entity) + the read:
[PreferredCulture] Culture Locale;
string cur = Session.CurrentCulture;                   // the current viewer's effective culture token

Description#

The culture is explicit, never ambient. There is no "current culture" read from the machine or the request — a value formats for the culture you name, or for the app default. This is what makes a price render the same on the server and in every browser.

Declared, closed set. A culture token on a format call must belong to the app's declared cultures { } set. The platform ships a default pack (en-US, en-GB, sv-SE, de-DE, fr-FR, es-ES, it-IT, nl-NL, pt-BR, ja-JP, zh-CN); an app can vendor the block to curate. A token outside the set is a compile error (with a "did you mean").

Supported formats (run in the browser, no round trip). Over a Decimal, Int32, Double (fixed specifiers only), DateTime, DateOnly or TimeOnly:

KindFormatsExample (sv-SE)
NumberN F D (+ digits: N2, F0)1 234,56
CurrencyC (+ digits)1 234,56 kr
PercentP (+ digits)12,5 %
Date/timed D t T g G M Y2024-01-05 · fredag 5 januari 2024
Custom numeric#,##0.00, 0.001 234,56
Custom dateyyyy-MM-dd, dd/MM/yyyy, MMMM d2024-01-05

The client reproduces .NET exactly — the culture's separators (including a non-breaking-space group separator and a minus), currency symbol and placement, month/day names (with the genitive forms .NET selects when a day number is adjacent, e.g. German 5. Juni vs standalone Jun), and AM/PM.

A Double formats client-side too for the fixed specifiers (N/F/C/P) — the client rounds the value's exact decimal expansion (a double is a terminating decimal), reproducing .NET including its type-specific rounding (a double rounds half-to-even, (2.5).ToString("F0")"2", where a Decimal rounds half-away-from-zero). Prefer Decimal for money regardless — it is exact end to end.

Everything else runs on the server (correct, one round trip): parsing a string back to a value; the numeric E/G/R/X specifiers, a custom pattern over a double, and a double's plain .ToString() — these need shortest-round-trip / 15-digit rounding that isn't reproduced client-side; non-Gregorian date calendars/eras under a culture; and any format built at runtime.

App default. app.DefaultCulture = "sv-SE"; makes a culture-less value.ToString("C") format for sv-SE everywhere — server and client — without repeating the culture at each call. A per-call culture still overrides it. With no default declared, a culture-less format is culture-neutral (invariant), unchanged.

Per-viewer culture. Give the principal entity a Culture property marked [PreferredCulture], and a culture-less value.ToString("C") renders in the current signed-in user's culture. Culture is a value-kind that stores a BCP-47 token ("de-DE") — a picker or a plain string sets it — validated to be a real culture when written. The resolution order is: a per-call culture > the viewer's [PreferredCulture] > app.DefaultCulture > invariant. It resolves at runtime and, like every other supported format, runs in the browser (the viewer's culture is served with the app's metadata, so there is no round trip); a viewer whose preferred culture is not one the app declared falls back to the app default.

A number typed into a box follows the same order. NumberField and DecimalField — and any Field bound to a numeric member — render and accept their digits in the viewer's effective culture, not the browser's. So under app.DefaultCulture = "sv-SE" the box shows 12,5 and takes 12,5, 1 234,50 and -12,5, and the member behind it still holds an exact decimal. A number written with another culture's decimal mark is not a number: 12.5 there answers what an empty box answers, rather than being read as 125 or as 12.5 — either of which would be a wrong number nobody could see.

min/max on the numeric controls mark the box invalid the moment the value falls outside, so the reader sees it as they type rather than when the save is refused.

Reading the viewer's culture. Session.CurrentCulture returns that effective token — the viewer's [PreferredCulture] if set, else app.DefaultCulture, else "" (invariant) — as a value, so you can branch on it (if (Session.CurrentCulture == "de-DE") …) or pass it on. It is the author-facing sibling of Session.CurrentUser, resolves the same way as the implicit formatting above, and runs in the browser with no round trip.

Parsing (the inverse). Convert.ToDecimal(s, "sv-SE") reads a number written in a culture ("1 234,56"1234.56), returning 0 for a string it can't parse (the Convert.To* contract). It runs client-side too, byte-identical to the server, over a defined profile: leading/trailing whitespace, one sign, group separators, and a decimal separator. Outside that profile — a currency symbol in the string, parentheses for a negative, an exponent — parses server-side (a round trip). A plain Convert.ToDecimal(s) (no culture) already parses invariantly in the browser.

Convert.ToInt(s, "de-DE") is the integer twin — it runs client-side too, over the same profile minus a decimal point and minus a trailing sign (a "1234-" is a value for ToDecimal but 0 for ToInt). A value outside the 32-bit integer range parses to 0, like every unparseable string.

Parsing a date exactly. DateTime.ParseExact(s, "yyyy-MM-dd", "sv-SE") reads a date/time written to a specific pattern — the inverse of the date formatter. It is STRICT: a string that does not match the pattern throws (unlike the number parses, which return 0), exactly as .NET's DateTime.ParseExact does. It runs client-side for a literal custom pattern that carries a full date — a 4-digit year (yyyy), a month, and a day-of-month — plus optional time, using the supported tokens y M d H h m s t with separators and quoted literals; the field widths are strict (MM wants two digits, "2024-6-15" is rejected), month/day names honour the culture (MMMM/dddd, genitive included), and a day-name token must agree with the date. A single-char standard specifier ("d", "D"), a 2-digit year (yy — its value depends on the culture's century window), or a dynamic pattern/culture stays server-side. The lenient, multi-pattern DateTime.Parse(s) (no explicit pattern) also stays server-side.

Examples#

Format money and a percentage for an explicit culture:

string Price(decimal amount) {
  return amount.ToString("C", "sv-SE");     // "1 234,56 kr"
}

string Rate(decimal ratio) {
  return ratio.ToString("P1", "en-US");     // "12.5 %"
}

Format a date, standard and custom:

string LongDate(DateTime when) {
  return when.ToString("D", "de-DE");        // "Freitag, 5. Januar 2024"
}

string Iso(DateTime when) {
  return when.ToString("yyyy-MM-dd", "sv-SE");
}

Parse a number a user typed in their culture (runs in the browser):

decimal ReadPrice(string entered) {
  return Convert.ToDecimal(entered, "sv-SE");    // "1 234,56" → 1234.56, "" → 0
}

int ReadQuantity(string entered) {
  return Convert.ToInt(entered, "de-DE");        // "1.234" → 1234, "12,5" → 0 (no decimal point)
}

DateTime ReadDate(string entered) {
  return DateTime.ParseExact(entered, "dd/MM/yyyy", "en-GB");   // "25/12/2024"; a mismatch throws
}

Set an app-wide default so a culture-less format still localises:

app.DefaultCulture = "sv-SE";

string LocalPrice(decimal amount) {
  return amount.ToString("C");               // uses sv-SE → "1 234,56 kr"
}

Let each viewer see prices in their own culture — mark a Culture property on the principal [PreferredCulture]:

app.DefaultCulture = "sv-SE";

[Principal] entity Member {
  [Required, MaxLength(60)] string Email;
  [PreferredCulture] Culture Locale;         // a BCP-47 token, e.g. "de-DE"
}

string ViewerPrice(decimal amount) {
  return amount.ToString("C");               // renders in the signed-in member's Locale, else sv-SE
}

string ViewerCulture() {
  return Session.CurrentCulture;             // the effective token: the member's Locale, else "sv-SE"
}

See also#

Related

Encoding — Base64, URL, HTML

Encode and decode text — Base64, URL percent-encoding, and HTML escaping — with the C#-faithful spellings…

DateTime

A date and time. It is a wall-clock value, not an instant on a timeline, so it is never shifted by anybody's timezone…