format brings Python-style braces and printf-style mini-languages to Dart
while keeping Dart SDK number conversion as the default. It supports positional
and named values, Unicode-aware alignment, locale-aware numbers, and custom
formatters.
import 'package:format/format.dart';
format('{} {}', 'hello', 'world');
format('{1} {0}', 'hello', 'world');
formatWith('{name}: {value}', named: {'name': 'answer', 'value': 42});The public formatting API consists of:
format(String template, [Object? value1, ..., Object? value10]);
formatWith(
String template, {
List<Object?> positional = const [],
Map<String, Object?> named = const {},
});
sprintf(String template, [Object? value1, ..., Object? value10]);
vsprintf(String template, List<Object?> values);Literal width and precision are supported in templates:
format('{:08d}', 42); // 00000042
format('{:>10.2f}', 12.34); // 12.34
format('{:*^9s}', 'hello'); // **hello**Use doubled braces to emit literal braces:
format('{{value}} = {0}', 42); // {value} = 42The same doubling works inside a format specification, but there the two
forms must balance: {{ requires a later }}, because the first unescaped
} is what ends the specification. In ordinary text they are independent, so
a lone {{ is fine there and a lone { is not.
Version 2.0.0 was never published, so upgrading from 1.6.0 takes the 2.0, 3.0, and 4.0 changes together. The CHANGELOG lists them in full, and Migration from 1.6.0 describes what an upgrade has to change in calling code.
- Two mini-languages on one engine. Braces stay in
formatandformatWith; the printf dialect arrives assprintfandvsprintf, with C-style conversions for text, integers, and floating point. - Configuration is an object, not a global. A
Formatinstance carries the number locale, the text unit, thedoubleprofile, and the registered custom formatters, attribute lookups, and representations. Instances are immutable and there is no global registry to mutate. - The
Stringextension is gone. 1.6.0 formatted through'{}'.format(x)as well; 3.0 exports top-level functions only. - Failures are typed. Every one of them is a
FormattingExceptionsubclass that carries the position in the template — see Error classes. The hierarchy is separate fromdart:core'sFormatExceptionand does not extend it, soon FormatExceptioncatches nothing this package throws. doubleconversion follows the Dart SDK by default. Double formatting profiles switches to the Python/C++ compatible profile where its rounding, exponent layout, extended precision, andinf/nanspellings are wanted.intlis no longer a dependency. Number locales define the separators, digits, and grouping, the C locale is the default, andpackage:format_intlsupplies anintl-backed locale for applications that want one. Code that relied on 1.6.0 reading the ambientintllocale fornhas to pass a locale explicitly.- Width counts what you choose. Unicode text units align by grapheme clusters, Unicode scalars, or UTF-16 code units.
- Templates are compiled and cached. 1.6.0 parsed the template with regular expressions on every call. 3.0 compiles it into a program of typed operations and keeps that program in a template cache bounded by entry count and by memory. This is where most of the difference measured below comes from.
Each figure below is a ratio: how many times faster 3.0 formats the same template and the same values than 1.6.0 does. Both versions run in one process against the same values, so the machine cancels out of the comparison.
| template | values | Dart VM | dart2js | dart2wasm |
|---|---|---|---|---|
{} |
'hello world' |
17.8× | 3.0× | 22.5× |
{:d} |
-12345 |
9.3× | 2.0× | 11.5× |
{:d} |
the runtime's largest exact integer | 2.5× | 1.2× | 7.5× |
{:10d} |
1 |
10.8× | 3.0× | 9.4× |
{:,d} |
1234567 |
16.1× | 4.1× | 19.4× |
{:010,d} |
1234 |
28.1× | 6.5× | 19.9× |
{:.2f} |
0.1 |
8.8× | 2.7× | 7.9× |
fifty {} fields |
0…49 |
11.7× | 2.8× | 25.4× |
Over the whole matrix of thirty-one cases the gain is 2.4× to 28.1× on the VM, 6.5× to 25.4× under dart2wasm, and 1.1× to 6.4× under dart2js. The narrowest gains are on integers at the platform's exact limit, where writing nineteen or twenty digits is most of the call and 1.6.0's per-call overhead weighs least; the widest are on grouped or zero-padded integers and on long templates, where 1.6.0 pays for a regular expression per call and 3.0 pays for nothing it has already compiled.
The cache is most of that difference, and it is the part a workload can lose. With it off, the VM keeps 1.7× to 8.9× and dart2wasm 1.5× to 7.6×, but dart2js falls behind 1.6.0 on twenty-six of the thirty-one cases, between 0.46× and 2.1×: 1.6.0's regular expressions run on the JavaScript engine's own regex implementation, while the 3.0 scanner is compiled JavaScript. When to turn it off describes the workloads where the cache does not pay for itself.
sprintf has no counterpart here — 1.6.0 had no printf dialect — so the table
has no row for it; the benchmark compares it against package:sprintf instead.
Measured on an Apple M3 Max (macOS 26.5.2, Dart 3.13.0, Node v26.5.0) as the minimum over the measured rounds, against a vendored copy of 1.6.0, from a clone of the repository rather than the published package:
cd benchmark/suite
dart run tool/run.dart --runtime=vm -- --full
dart run tool/run.dart --runtime=js -- --full
dart run tool/run.dart --runtime=wasm -- --fullAnother machine will print other numbers. What carries over is the ordering between templates and between runtimes, not the ratios themselves.
template = (literal | "{{" | "}}" | replacement_field)*
replacement_field = "{" field_name? lookup* conversion? format_spec? "}"
field_name = decimal_index | python_identifier
lookup = "." python_identifier | "[" item_key "]"
automatic xor manual positional numbering
conversion = "!" ("s" | "r" | "a")
format_spec may contain replacement_field at depth 1
| Syntax | Meaning |
|---|---|
template = (literal | "{{" | "}}" | replacement_field)* |
Doubled braces emit literals. |
replacement_field = "{" field_name? lookup* conversion? format_spec? "}" |
Field parts have this order. |
field_name = decimal_index | python_identifier |
Empty is automatic; Python Unicode decimal digits are positional; an identifier is named. |
lookup = "." python_identifier | "[" item_key "]" |
Unicode identifier attributes and non-empty unquoted item chains. |
automatic xor manual positional numbering |
Automatic and numeric roots never mix. |
conversion = "!" ("s" | "r" | "a") |
Conversion precedes the specification. |
format_spec may contain replacement_field at depth 1 |
One nested level; nested specifications cannot nest again. |
[[fill]align][sign]["z"]["#"]["0"][width][grouping]["." (precision [grouping] | grouping)][type | custom_name [":" payload]]
custom_name = ASCII_LETTER (ASCII_LETTER | ASCII_DIGIT | "_")*
| Syntax | Meaning |
|---|---|
[[fill]align][sign]["z"]["#"]["0"][width][grouping]["." (precision [grouping] | grouping)][type | custom_name [":" payload]] |
Exact option order. |
custom_name = ASCII_LETTER (ASCII_LETTER | ASCII_DIGIT | "_")* |
Built-ins reserved, payload follows colon. |
| Tokens | Meaning | Default | Applies to |
|---|---|---|---|
<, >, ^, = |
Fill with one text unit and align left, right, center, or after the sign. | Text and custom output align left; numbers align right. Zero implies sign-aware alignment when align is absent. | String, int, BigInt, double, custom value |
+, -, |
Select the sign for numeric output. | Minus only. | int, BigInt, double, custom value |
z |
Remove the minus when rounding produces zero. | Off; clears a sign only after rounding to zero. | double, custom value |
# |
Request a radix prefix or decimal point. | Off; a radix prefix or forced decimal point, with no visible prefix for decimal integers. | int, BigInt, double, custom value |
0 |
Request sign-aware numeric zero padding. | Off; numeric sign-aware zero padding, passed through to a custom formatter. | int, BigInt, double, custom value |
ASCII_DIGIT+ |
Set the minimum field width. | Absent; range 0…100000. | String, int, BigInt, double, custom value |
,, _ |
Group integer digits with comma or underscore. | Absent; comma is decimal-only, underscore supports every non-locale radix, and custom formatters receive only this separator. | int, BigInt, double, custom value |
.ASCII_DIGIT+ |
Truncate text or control numeric precision. | Absent; truncates text, controls numeric digits, and passes an integer value to a custom formatter. | String, double, custom value |
.,, ._, precision suffix ,, precision suffix _ |
Group fractional digits after rounding. | Absent; accepted syntactically but not exposed to custom formatters. | double |
built-in letter, custom_name |
Select a built-in presentation or custom formatter. | Inferred from the value when empty. | any value |
:balanced specification text |
Pass resolved text after the custom formatter name. | Absent differs from empty; nested fields resolve before the callback. | custom value |
| Type | Accepts | Allowed option tokens | Result | Default precision |
|---|---|---|---|---|
| empty | any value | <, >, ^ (String, int, BigInt, double only); = (int, BigInt, double only); +, -, (int, BigInt, double only); z (double only); # (int, BigInt, double only); 0 (int, BigInt, double only); ASCII_DIGIT+ (String, int, BigInt, double only); ,, _ (int, BigInt, double only); .ASCII_DIGIT+ (String, double only); .,, ._, precision suffix ,, precision suffix _ (double only); empty |
Value-default text or one matching custom formatter. | Depends on the value type. |
s |
String |
<, >, ^; ASCII_DIGIT+; .ASCII_DIGIT+; s |
Text, optionally truncated. | Not specified. |
c |
int, BigInt |
<, >, ^; ASCII_DIGIT+; c |
One Unicode scalar. | Not specified. |
d |
int, BigInt |
<, >, ^, =; +, -, ; #; 0; ASCII_DIGIT+; ,, _; d |
Exact decimal integer. | Not specified. |
b |
int, BigInt |
<, >, ^, =; +, -, ; #; 0; ASCII_DIGIT+; _; b |
Exact binary integer. | Not specified. |
o |
int, BigInt |
<, >, ^, =; +, -, ; #; 0; ASCII_DIGIT+; _; o |
Exact octal integer. | Not specified. |
x, X |
int, BigInt |
<, >, ^, =; +, -, ; #; 0; ASCII_DIGIT+; _; x, X |
Exact lower- or uppercase hexadecimal integer. | Not specified. |
n |
int, BigInt, double |
<, >, ^, =; +, -, ; z (double only); #; 0; ASCII_DIGIT+; .ASCII_DIGIT+ (double only); n |
Locale-aware decimal or general number. | Floating values use the general default. |
f, F |
int, BigInt, double |
<, >, ^, =; +, -, ; z; #; 0; ASCII_DIGIT+; ,, _; .ASCII_DIGIT+; .,, ._, precision suffix ,, precision suffix _; f, F |
Fixed-point number. | 6 fractional digits |
e, E |
int, BigInt, double |
<, >, ^, =; +, -, ; z; #; 0; ASCII_DIGIT+; ,, _; .ASCII_DIGIT+; .,, ._, precision suffix ,, precision suffix _; e, E |
Scientific notation. | SDK shortest exponent or compatible precision 6 |
g, G |
int, BigInt, double |
<, >, ^, =; +, -, ; z; #; 0; ASCII_DIGIT+; ,, _; .ASCII_DIGIT+; .,, ._, precision suffix ,, precision suffix _; g, G |
General decimal notation. | SDK shortest or compatible significant precision 6 |
% |
int, BigInt, double |
<, >, ^, =; +, -, ; z; #; 0; ASCII_DIGIT+; ,, _; .ASCII_DIGIT+; .,, ._, precision suffix ,, precision suffix _; % |
Value multiplied by 100 with a percent suffix. | 6 fractional digits |
ASCII name |
custom value | <, >, ^; +, -, ; z; #; 0; ASCII_DIGIT+; ,, _; .ASCII_DIGIT+; ASCII name; :balanced specification text |
Custom callback output with engine-applied layout. | Not specified. |
template = (literal | conversion)*
conversion = "%" flags width? precision? type
flags = ("-" | "+" | " " | "#" | "0")*
width = ASCII_DIGIT+ | "*"
precision = "." (ASCII_DIGIT* | "*")
no "$" positions; no h/l/j/z/t/L modifiers
| Syntax | Meaning |
|---|---|
template = (literal | conversion)* |
Percent begins every conversion. |
conversion = "%" flags width? precision? type |
Fixed order. |
flags = ("-" | "+" | " " | "#" | "0")* |
Repeats collapse; + beats space, - beats zero. |
width = ASCII_DIGIT+ | "*" |
Dynamic width is consumed before precision and value. |
precision = "." (ASCII_DIGIT* | "*") |
Empty is zero; negative dynamic precision is absent. |
no "$" positions; no h/l/j/z/t/L modifiers |
Unsupported C/POSIX syntax is rejected. |
| Tokens | Meaning | Default | Applies to |
|---|---|---|---|
ASCII_DIGIT+, * |
Set literal or argument-supplied minimum width. | Absent for every value conversion; %% forbids it. |
any value |
.ASCII_DIGIT*, .* |
Set literal or argument-supplied precision. | Absent for text, integer, and floating conversions; %c and %% forbid it. |
String, int, BigInt, double |
| Flag | Allowed conversions | Meaning | Default |
|---|---|---|---|
- |
s, c, d, i, u, o, x, X, f, F, e, E, g, G, a, A |
Left-align the converted value. | Right alignment for every value conversion. |
+ |
d, i, f, F, e, E, g, G, a, A |
Show a plus sign for a non-negative signed value. | Minus only. |
|
d, i, f, F, e, E, g, G, a, A |
Prefix a non-negative signed value with a space. | Off; ignored when + exists. |
# |
o, x, X, f, F, e, E, g, G, a, A |
Request the conversion's alternate form. | Off. |
0 |
d, i, u, o, x, X, f, F, e, E, g, G, a, A |
Pad a numeric conversion with zeros. | Off; disabled by -, and for integers by precision. |
| Type | Accepts | Allowed option tokens | Result | Default precision |
|---|---|---|---|---|
s |
any value | -; ASCII_DIGIT+, *; .ASCII_DIGIT*, .* |
toString() text, optionally truncated. |
Not specified. |
c |
int, BigInt |
-; ASCII_DIGIT+, * |
One Unicode scalar. | Not specified. |
d, i |
int, BigInt |
-; +; ; 0; ASCII_DIGIT+, *; .ASCII_DIGIT*, .* |
Signed decimal integer. | No leading precision zeros. |
u |
non-negative int, BigInt |
-; 0; ASCII_DIGIT+, *; .ASCII_DIGIT*, .* |
Non-negative decimal integer. | No leading precision zeros. |
o |
non-negative int, BigInt |
-; #; 0; ASCII_DIGIT+, *; .ASCII_DIGIT*, .* |
Non-negative octal integer. | No leading precision zeros. |
x, X |
non-negative int, BigInt |
-; #; 0; ASCII_DIGIT+, *; .ASCII_DIGIT*, .* |
Non-negative hexadecimal integer. | No leading precision zeros. |
f, F |
double |
-; +; ; #; 0; ASCII_DIGIT+, *; .ASCII_DIGIT*, .* |
Fixed-point double. | 6 fractional digits |
e, E |
double |
-; +; ; #; 0; ASCII_DIGIT+, *; .ASCII_DIGIT*, .* |
Scientific double. | SDK exponent spelling when absent, otherwise requested; compatible default 6 |
g, G |
double |
-; +; ; #; 0; ASCII_DIGIT+, *; .ASCII_DIGIT*, .* |
General decimal double. | SDK toString when absent; compatible significant precision 6 |
a, A |
double |
-; +; ; #; 0; ASCII_DIGIT+, *; .ASCII_DIGIT*, .* |
Exact hexadecimal binary64 notation. | Exact trimmed notation |
% |
— | — | Literal percent; no value consumed. | Not specified. |
| Rule | Contract |
|---|---|
| Safe option size | brace and printf literal width/precision ≤ 100000 |
| Dynamic width | printf dynamic width −100000…100000; negative means left alignment |
| Dynamic precision | printf dynamic precision ≤ 100000; every negative value means absent |
| Fill expansion | width * fill.length ≤ 200000 UTF-16 code units |
| Field indexes | brace positional and numeric item index ≤ 9223372036854775807 |
| Nesting depth | one nested replacement-field level |
| Dart-profile precision | Dart profile: general/empty/`n` 1…21; `f`/`e`/`%` 0…20 |
| Compatible-profile precision | compatible profile accepts 0…100000; `g` precision 0 behaves as 1 |
| Rule | Contract |
|---|---|
| Malformed template | InvalidFormatException |
| Inapplicable options | InvalidSpecifierException |
| Unsupported value | UnsupportedFormatValueException |
| Unsupported brace conversion | UnsupportedConversionException |
| Missing argument | MissingFormatArgumentException |
The complete token set for text is in the brace presentation matrix.
Fill, alignment, and width apply to whatever the placeholder produced, and precision truncates text rather than rounding it:
format('{:>8s}', 'hi'); // hi
format('{:.3s}', 'abcdef'); // abcZero padding is a numeric option, so a text specification rejects it instead of quietly padding with zeros:
format('{:05s}', 'abc'); // throws InvalidSpecifierExceptionWidth and precision are read as ASCII digits. Other Unicode digits are a specification error, though they remain usable in a field index or key, where they name an argument rather than a count:
format('{:٥d}', 1); // throws InvalidSpecifierException
formatWith('{٠}', positional: ['first']); // firstThe exact brace and printf option sets for characters are in the brace presentation matrix and printf conversion matrix.
The c conversion turns a number into the character it encodes, in both
mini-languages:
format('{:c}', 0x41); // A
sprintf('%c', 0x41); // AThe value must be a Unicode scalar. A lone surrogate or a value above
0x10FFFF is rejected rather than producing a broken string, and zero padding
is a numeric option here too:
format('{:c}', 0xD800); // throws UnsupportedFormatValueException
format('{:c}', 0x110000); // throws UnsupportedFormatValueException
format('{:05c}', 0x41); // throws InvalidSpecifierExceptionThe brace presentation matrix and printf conversion matrix link text conversions back here because these units govern their width, precision, and fill behavior.
Width and precision count Unicode scalar values by default. Configure grapheme clusters when emoji and combined characters should count as one visible character each:
final graphemeFormat = Format(textUnit: TextUnit.graphemeClusters);
format('{:.3s}', '👩👩👧👦ab'); // 👩👩 — three scalars
graphemeFormat.format('{:.3s}', '👩👩👧👦ab'); // 👩👩👧👦ab — three clusters
graphemeFormat.format('{:*<5s}', '👩👩👧👦'); // 👩👩👧👦****The unit also decides what counts as a single fill character, so a multi-scalar fill needs the grapheme mode:
graphemeFormat.format('{:🇰🇿^13s}', 'Қазақстан'); // 🇰🇿🇰🇿Қазақстан🇰🇿🇰🇿
format('{:🇰🇿^13s}', 'Қазақстан'); // throws InvalidSpecifierExceptionTextUnitOperations exposes the same measurement the engine uses, for code
that needs to lay out text alongside it:
TextUnit.graphemeClusters.length('👩👩👧👦ab'); // 3
TextUnit.unicodeScalars.length('👩👩👧👦ab'); // 9The numeric rows and their default precisions are collected in the brace presentation matrix and printf conversion matrix.
Decimal double conversions use the Dart SDK by default. In particular, f,
e, and g delegate to toStringAsFixed, toStringAsExponential, and
toStringAsPrecision when a precision is present. The no-precision g and
empty conversions use toString():
format('{:.0f}', 2.5); // 3
format('{:e}', 1.0); // 1e+0
format('{:.3g}', 1.0); // 1.00
format('{}', double.infinity); // InfinitySDK precision limits therefore apply: f, e, and % accept 0 through 20,
while g and n accept 1 through 21. A specification with a precision but no
type counts as g here, so format('{:.0}', 2.0) is rejected in this mode and
gives 2e+00 in the compatible one. As with toStringAsFixed, f may use
exponential notation for magnitudes at or above 10^21.
Select DoubleFormatMode.compatible when exact Python brace-formatting and
C++ printf rounding and spelling are required:
final compatible = Format(
doubleFormatMode: DoubleFormatMode.compatible,
);
compatible.format('{:.0f}', 2.5); // 2
compatible.format('{:e}', 1.0); // 1.000000e+00
compatible.format('{:.3g}', 1.0); // 1
compatible.format('{}', double.infinity); // infCompare both profiles on the current machine with the ANSI-colored benchmark, from a clone of the repository — the benchmarks are not part of the published package:
cd benchmark/suite
dart run bin/double_modes_benchmark.dartFor finite double values in the benchmark scenarios,
DoubleFormatMode.dartSdk is faster than compatible mode or falls within the
default 5% equivalence threshold. The report prints both formatted results and
median times; this performance conclusion does not include NaN or
Infinity, and it is measured on the Dart VM — the benchmark runs there, and
the two modes have not been compared under dart2js. VS Code also provides the
Benchmark: double modes launch configuration.
In Dart SDK mode, non-finite values are NaN and Infinity by default. Their
short spellings can be selected independently; compatible mode always uses
short spellings:
final shortSpecials = Format(
doubleSpecialValueSpelling: DoubleSpecialValueSpelling.short,
);
shortSpecials.format('{}', double.nan); // nan
shortSpecials.sprintf('%F', double.infinity); // INFUse sprintf for direct arguments and vsprintf for a list:
sprintf('%s: %#08x', 'answer', 42); // answer: 0x00002a
vsprintf('%*.*f', [8, 2, 1.5]); // 1.50The complete set of letters, accepted values, and flags is in the
printf conversion matrix. Width and precision may
be literals or * arguments. Decimal floating-point conversions use the
selected double profile: Dart SDK semantics by default, or deterministic
C++23-compatible nearest-even rounding and inf/nan spelling in compatible
mode. In the default profile sprintf('%e', 12.5) returns 1.25e+1, not the C
1.250000e+01: select DoubleFormatMode.compatible when C-exact decimal output
is required. Negative unsigned values are rejected instead of wrapped.
This Dart dialect intentionally omits %n, %p, C length modifiers, POSIX
$ argument indexing, and C++26 %b/%B. String width and precision use the
configured Unicode TextUnit; %c accepts a Unicode scalar; %s calls
toString() for non-string Dart values; and int/BigInt are not truncated
to a C machine width. A configured NumberLocale, including one supplied by
format_intl, may localize signs, separators, and digits beyond the normative
LC_ALL=C compatibility profile.
The locale-aware brace and printf rows are indexed in the brace presentation matrix and printf conversion matrix.
The n presentation type reads a NumberLocale. The , and _ grouping
flags do not: they always write the separator they name, exactly as CPython
does, so '{:,d}' is 1,234,567 under every locale and only '{:n}' follows
the configured one.
The printf dialect answers differently, and deliberately: %f, %e, %g and
%a write the locale's decimal separator, because that is what C does with
LC_NUMERIC, while the brace dialect keeps . because that is what Python
does. Under a locale that separates decimals with a comma, '{:.2f}' is
1234.50 and '%.2f' is 1234,50 — each dialect follows the language it
comes from rather than the other one.
The default locale is the C locale, which groups with ,, separates decimals
with ., and leaves n ungrouped:
format('{:,.2f}', 1234567.5); // 1,234,567.50
format('{:n}', 1234567); // 1234567Implement NumberLocale for a locale of your own, or use the optional
format_intl package, which adapts
intl locale data without adding intl to this package's dependencies:
import 'package:format_intl/format_intl.dart';
final kazakh = Format(numberLocale: IntlNumberLocale('kk_KZ'));
kazakh.format('{:.8n}', 123456.789);The printf dialect has no n, so every numeric conversion reads the locale —
%d and %x take its digits and signs, %f and %e its separators too. It
never groups on its own: a template that did not ask for separators does not
get them.
A locale localizes digits, and only digits: localizeDigits is handed the
ASCII 0–9 of a number and nothing else. In %x, %X and %#o that means
the digits change and the hexadecimal letters do not — under a locale with
Eastern Arabic digits, sprintf('%x', 0xabc123) is abc١٢٣ — and in %a the
mantissa digits and exponent are localized while the 0x prefix and the p
that marks the exponent stay as they are. C localizes none of this, and Python
has no such conversions; the mixed script is the price of localizing the digits
of a conversion whose letters are not digits.
A locale may localize signs, separators, and digits beyond what the C locale core specifies; the compatibility fixtures pin only the C locale behavior.
The accepted built-in layout tokens and callback-specific payload token are in the brace presentation matrix.
Implement Formatter<T>, then provide it to an immutable Format instance:
final class JsonFormatter extends Formatter<Map<String, Object?>> {
@override
String get specifier => 'json';
@override
String format(Map<String, Object?> value, FormatOptions options) =>
value.toString();
}
final jsonFormat = Format(formatters: [JsonFormatter()]);
jsonFormat.format('{:json}', <String, Object?>{'answer': 42});The engine checks a value against the formatter's T before any extension
code runs. canFormat(T value) returns true by default, so an ordinary
formatter only implements format. Override the typed predicate only for a
narrower condition within T, for example
bool canFormat(Money value) => value.currency == 'KZT'.
Custom specifiers must match [A-Za-z][A-Za-z0-9_]*. Built-in names are
reserved. For a placeholder without an explicit specifier, built-in types take
priority, followed by a unique matching custom formatter, then toString().
A formatter is therefore never consulted for a value the engine already
renders: one that accepts everything still leaves {} on a String or an
int to the built-in path, and only an explicit {:name} reaches such a
value. When two formatters accept the same value and the placeholder names
neither, the engine throws AmbiguousFormatterException rather than picking
one.
Automatic selection needs the specification to be empty, not merely
nameless: {:>12} on a custom value carries options and names nothing, so it
never reaches the registry and is rejected as a specification. Name the
formatter — {:>12money} — or leave the specification empty. Options alone do
not select one, because then registering an extension would change what an
unrelated {:>12} elsewhere in the program means.
Width, fill, and alignment are applied by the engine after a custom formatter
returns, while FormatOptions provides sign, alternate form, zero, grouping,
precision, and the optional additional template. That template is the text
after a second :; the formatter reads it from FormatOptions.payload and
interprets it however it likes:
jsonFormat.format('{:json:pretty}', <String, Object?>{'answer': 42});
// JsonFormatter.format receives options.payload == 'pretty'A payload lives inside the specification, so it inherits the balancing rule above: braces reach it doubled, and a lone one cannot be carried at all.
jsonFormat.format('{:json:a{{b}}c}', <String, Object?>{'answer': 42});
// options.payload == 'a{b}c'
jsonFormat.format('{:json:a{{b}', <String, Object?>{'answer': 42});
// throws InvalidFormatException — the {{ has no matching }}FormatOptions describes the specification, not the engine: a formatter
receives no NumberLocale, TextUnit or DoubleFormatMode. So grouping is
the flag as written (, or _), not the separator to write with — a
formatter that groups digits itself needs the locale, and the application
hands it over the same way it hands it to the engine:
const locale = MyLocale();
final engine = Format(
numberLocale: locale,
formatters: [MoneyFormatter(locale)],
);Dart has no reflection, so {value.attribute} resolves only through a
registered AttributeLookup. Without one, the engine throws
FormatLookupException:
final class PointLookup extends AttributeLookup<Point> {
@override
Object? lookup(Point value, String attribute) => switch (attribute) {
'x' => value.x,
_ => throw ArgumentError.value(attribute, 'attribute'),
};
}
final pointFormat = Format(lookups: [PointLookup()]);
pointFormat.formatWith('{p.x}', named: {'p': const Point(7)}); // 7The same typed default applies to canLookup: the engine first checks
Point, and the inherited method accepts every Point. Override
bool canLookup(Point value) only to select a subset of points.
A Map is the exception: {value.name} on a map is a shorthand for the
string key 'name', resolved before any lookup is consulted, so a lookup that
accepts maps is never called for one.
formatWith('{value.name}', named: {
'value': {'name': 'Ada'},
}); // AdaAn [item] key is literal text, as in Python: whatever stands between the
brackets is the key, quotes included. A key that begins with a quote is
refused instead, because that is Python's dict syntax written by mistake and
the error says so rather than reporting a key that was never there:
formatWith("{0[it's]}", positional: [{"it's": 'fine'}]); // fine
formatWith("{0['key']}", positional: [{"'key'": 1}]); // throwsImplement Representation<T> to give a type its own !r and !a form.
Built-in representations take priority the same way built-in formatters do,
and !a escapes non-ASCII characters in whatever the representation returned:
final class MoneyRepresentation extends Representation<Money> {
@override
String represent(Money value) => '${value.cents}¢';
}
final moneyRepr = Format(representations: [MoneyRepresentation()]);
moneyRepr.format('{!r}', const Money(250)); // 250¢
moneyRepr.format('{!a}', const Money(250)); // 250\xa2canRepresent(Money value) likewise returns true by default and is needed
only when a representation accepts a subset of Money values.
Anything an extension throws is caught and rethrown as
FormatExtensionException, which carries the original error and
stackTrace along with the template location. The exception to that is a
FormattingException: an extension reporting a failure in the engine's own
vocabulary has it passed through unchanged.
A value outside an extension's T is an ordinary non-match: the engine does
not call its typed predicate. Errors Dart raises inside extension code are
wrapped the same way; for example, an extension that formats by calling the
engine again on the same value produces a StackOverflowError, which arrives
as FormatExtensionException rather than escaping the engine raw.
dart2js represents an int and an integral double as the same JavaScript
number. Format canonically treats that indistinguishable value as an integer:
empty, integer, !r, and container formatting spell both 42 and 42.0 as
42. Explicit floating-point specifiers such as f, e, g, and % still
select floating-point formatting. So does a specification that carries no type
but does carry a precision, a z, or a fraction separator — no integer
specification accepts any of those, so '{:.3}' is a floating specification
whatever the runtime believes the value to be, and format('{:.3}', 2.0) is
2.00 in a browser as it is everywhere else. The cost of that is the mirror
case: format('{:.3}', 2) also produces 2.00 on the web, where the VM and
CPython reject it. dart2js cannot tell the two values apart, so one of the two
answers has to give; this way the divergence hands back a string rather than an
exception. On the Dart VM, 42 and 42.0 remain distinct and empty formatting
produces 42 and 42.0 respectively. BigInt remains a separate value kind on
every platform.
The !r conversion produces a Dart-oriented representation, while !a also
escapes non-ASCII characters. Both are implemented by this package rather than
delegated to the value: there is no Dart equivalent of the Python object
protocol to call.
format('{0!r} {0!a}', 'строка');
// 'строка' '\u0441\u0442\u0440\u043e\u043a\u0430'Values are spelled with their Dart tokens, and a container keeps the iteration order of the collection it came from rather than being reordered:
format('{} {} {}', true, false, null); // true false null
format('{!r}', {'b': 1, 'a': 2}); // {'b': 1, 'a': 2}
format('{!r}', {'b', 'a'}); // {'b', 'a'}Nested double values follow the selected double profile and special-value
spelling. Empty Map and Set values are both represented as {}. This
ambiguity is intentional: non-empty values remain distinguishable by their
entries.
Parsed templates are cached, which is what makes repeated formatting cheap. How much cheaper depends on the template, and the spread is wide: parsing is paid per template, while formatting is paid per field, so the denser the template the smaller the share parsing takes. Measured on the package's own benchmark, a first call costs this much more than a cached one:
| template | Dart VM | dart2js |
|---|---|---|
literal {} |
4.5× | 14.1× |
ten {i:d} fields |
20.6× | 21.5× |
five %d conversions |
4.3× | 8.1× |
fifty %d conversions |
5.1× | 7.7× |
The two runtimes differ, and not in the direction the table might suggest at a glance: under dart2js parsing is dearer than on the VM while formatting is cheaper, so it is the cached call that pulls the quotient up. The cache is therefore worth more on the web, not less. Figures quoted anywhere in this README are measured on the Dart VM unless a runtime is named.
The cache is bounded, because templates can come from data and an unbounded cache would be an unbounded leak.
templateCacheCapacity; // 512 entries by default, per mini-language
templateCacheMemoryLimit; // 8 MiB by default, per mini-language
templateCacheSize; // how many parsed templates are resident
templateCacheMemory; // what they are estimated to hold
clearTemplateCache(); // discard them allThere are two bounds because a count of entries says nothing about their size. A workload with a few very large generated templates stays well inside the capacity while holding hundreds of megabytes, so the second bound is on memory: whichever binds first evicts.
That figure is an estimate, and it has to be — a Dart program cannot measure
the memory it holds. An entry is priced by a model of what caching it retains:
the template text as the key, the text each literal slices out of it, the code
units prepared for those literals on the VM, and a constant per parse node. The
constants are fitted to measured retention, and the model matters because the
same amount of text costs wildly different amounts depending on its shape —
around 1 byte per character for text with no fields, 4 with a few, 76 for a
template of nothing but {} and 120 for one of nothing but {:d}, where what
an entry holds is a parse node each rather than text. The default therefore
holds about 2 Mi characters of ordinary text, or 110 Ki of the dense kind: the
budget adapts where a count of characters could not.
An entry priced above the whole budget is formatted but never cached — emptying the cache for one that still would not fit costs every other template its parse and gains nothing.
Both bounds are per isolate and shared by every Format instance — a parsed
template does not depend on the engine that parsed it. Raise the capacity when
the working set is larger than the default and templates repeat; a set that
cycles past it keeps roughly capacity / size of itself resident, because a
full cache evicts at random rather than in order.
Set either to zero when templates are generated and never repeat, so that caching would only pay to evict:
templateCacheCapacity = 0; // discards what is cached, and keeps nothingYou do not have to: a cache that misses and evicts many times in a row stops being consulted by itself, and is consulted again after a while in case the workload has changed. What it holds is kept rather than discarded, so nothing is lost when it comes back. Measured on the cold path under dart2js, a call formatting a template it has never seen went from 600 ns to 250 for a literal template and from 4230 to 2240 for one of ten fields; on the VM 469 to 311 and 2337 to 2043; under dart2wasm 379 to 237 and 2424 to 1900. Setting the bound to zero is still the sharper instrument — it says so from the first call rather than after the misses that establish it, and it frees what is cached.
What decides is not how often a template repeats but whether the working set fits inside both bounds. If it fits, the cache pays for itself almost at once; if it does not, it never pays at all, at any repetition rate — an entry is evicted before the workload comes back to it. Measured as cached time over uncached time, so below 1 means the cache is winning:
| distinct templates | shape | ×1 | ×2 | ×3 | ×5 | ×10 |
|---|---|---|---|---|---|---|
| inside the bounds | ten {i:>8,d} fields |
1.16 | 0.66 | 0.50 | 0.36 | 0.26 |
| inside the bounds | ten {} fields |
1.47 | 0.93 | 0.69 | 0.54 | 0.45 |
| inside the bounds | one literal, no fields | 5.82 | 1.85 | 1.40 | 1.04 | 0.68 |
| past the bounds | ten {i:>8,d} fields |
1.34 | 1.30 | 1.28 | 1.33 | 1.30 |
| past the bounds | ten {} fields |
1.75 | 1.58 | 1.57 | 1.52 | 1.48 |
| past the bounds | one literal, no fields | 8.17 | 4.52 | 5.10 | 4.04 | 3.95 |
So a template with fields repays its own caching on the second use, and a template that is nothing but literal text takes until about the seventh — there is nothing to parse there, while the cache still charges two table operations. The rows past the bounds are flat, which is the point: repetition buys nothing once the set no longer fits.
Those three flat rows are what the cache now steps out of on its own — they are measured with it consulted throughout, which is what it used to do and what it still does until the misses add up. Turning it off outright is the difference between paying that for the first few hundred calls and not paying it at all.
Before turning the cache off, weigh raising both bounds so that the set does
fit — and size that with templateCacheMemory rather than by eye, because
capacity alone will not do it. What an entry holds depends on its shape far
more than on its length:
| shape | per entry | fit in the default 8 MiB |
|---|---|---|
| one literal, no fields | 5 bytes | about 1 750 000 |
ten {} fields |
about 1.7 KiB | about 5 000 |
ten {i:>8,d} fields |
about 5 KiB | about 1 640 |
A template with no fields is its own output and holds only the key, which is why it is nearly free to cache and also the one shape least worth caching. A field-dense template holds a parse node per field, so raising the capacity to 8192 without raising the memory limit leaves it evicting exactly as before.
Lowering either bound discards entries immediately, rather than at the next insertion.
templateCacheSize tells "the cache is too small for this workload" apart
from "this workload never repeats a template", which otherwise look alike from
the outside. Both it and templateCacheMemory are sums across the two
mini-languages, while the two bounds apply to each separately: a program using
braces and printf alike can read 1024 resident templates with the capacity at
512 and nothing be wrong. Read with templateCacheMemory, it also tells a
cache full of small templates from one held by a handful of large or
field-dense ones — the two need opposite adjustments. To see the difference
the cache makes on the current machine, the benchmark measures every case with
it on and off — again from a clone of the repository, not from the published
package:
cd benchmark/suite
dart run bin/benchmark.dart # on the Dart VM
dart run tool/run.dart --runtime=js # dart2js, under node
dart run tool/run.dart --runtime=wasm # dart2wasm, under nodetool/run.dart also takes --bin=, one of comparison, template_ir,
double_modes, list_snapshot, and compiles into a temporary directory.
The operations per round are calibrated to whichever clock the runtime has:
under dart2js it advances in whole milliseconds, so a count tuned on the VM
would print multiples of 50 ns and nothing between them.
Version 2.0.0 was never published to pub.dev, so migrating from the published 1.6.0 means adopting the 2.0, 3.0, and 4.0 changes together; all of them are described in the CHANGELOG. What they add, and how the result measures against 1.6.0, is in Key differences from 1.6.0.
Version 3.0 removed formatNamed and treats a List passed to format as one
value. Pass direct values separately, or use formatWith for positional and
named collections:
format('{} {}', 'hello', 'world');
formatWith(
'{name}: {value}',
named: {'name': 'answer', 'value': 42},
);Formatting failures use the typed FormattingException hierarchy. It is
separate from dart:core's FormatException and does not extend it, so
on FormatException catches nothing this package throws — catch
FormattingException. Configure custom formatters, lookups, representations,
locales, and text units by constructing a Format instance instead of mutating
global registries.
Dart SDK decimal double conversion is the default. Applications
that depend on Python/C++ rounding, exponent layout, precision beyond the Dart
SDK limits, or inf/nan spellings should construct a Format with
DoubleFormatMode.compatible. This setting applies consistently to brace
formatting, sprintf, and nested !r/!a representations.
Code written against 3.0.0 rather than 1.6.0 has one thing to change: 4.0.0
gives canFormat, canLookup, and canRepresent the extension's T and
filters the runtime type before calling them, so an override that only
repeated value is T can go, and any remaining filter takes a T.