format 3.0.0
format: ^3.0.0 copied to clipboard
Dart string formatting with Python-style braces and printf mini-languages.
format #
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.
Usage #
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} = 42
The 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.
Presentation types #
The letter that ends a brace specification chooses the presentation. Which
letters a value accepts follows from its own type, so a specification meant for
another type raises InvalidSpecifierException rather than converting
silently:
| type | accepts | produces |
|---|---|---|
| none | any value | 42, 2.5, hi, true, null — the value's own text |
s |
text | the text, precision truncating rather than rounding |
d |
int, BigInt |
decimal: 42 |
b |
int, BigInt |
binary: 101010 |
o |
int, BigInt |
octal: 52 |
x, X |
int, BigInt |
hexadecimal, lower or upper: 2a, 2A |
c |
int, BigInt |
the character that code point encodes |
n |
int, BigInt, double |
decimal in the configured locale |
f, F |
int, BigInt, double |
fixed point: 2.500000 |
e, E |
int, BigInt, double |
scientific: 2.5e+0, 2.5E+0 |
g, G |
int, BigInt, double |
general: 2.5 |
% |
int, BigInt, double |
the value times 100 with a percent sign |
A name that is not one of these is a custom formatter (see
Custom formatters), and bool and null reach only the
empty specification: no presentation type accepts them.
sprintf uses conversion letters instead, and decides from the letter rather
than from the value — so a value the conversion cannot render raises
UnsupportedFormatValueException:
| conversion | accepts | produces |
|---|---|---|
%s |
any value | the value's own text |
%c |
int, BigInt |
the character that code point encodes |
%d, %i |
int, BigInt |
decimal, signed |
%u |
int, BigInt ≥ 0 |
decimal, unsigned: a negative value is rejected |
%o |
int, BigInt ≥ 0 |
octal |
%x, %X |
int, BigInt ≥ 0 |
hexadecimal, lower or upper |
%f, %F |
double |
fixed point |
%e, %E |
double |
scientific |
%g, %G |
double |
general |
%a, %A |
double |
hexadecimal floating point: 0x1.4p+1 |
%% |
— | a literal percent sign; consumes no argument |
Unlike the brace types, the numeric printf conversions do not accept the other
numeric type: %d rejects a double and %f rejects an int, as in C.
Text formatting #
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'); // abc
Zero padding is a numeric option, so a text specification rejects it instead of quietly padding with zeros:
format('{:05s}', 'abc'); // throws InvalidSpecifierException
Width 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']); // first
Character values #
The c conversion turns a number into the character it encodes, in both
mini-languages:
format('{:c}', 0x41); // A
sprintf('%c', 0x41); // A
The 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 InvalidSpecifierException
Unicode text units #
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 InvalidSpecifierException
TextUnitOperations 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'); // 9
Double formatting profiles #
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); // Infinity
SDK 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); // inf
Compare 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.dart
For 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); // INF
sprintf #
Use sprintf for direct arguments and vsprintf for a list:
sprintf('%s: %#08x', 'answer', 42); // answer: 0x00002a
vsprintf('%*.*f', [8, 2, 1.5]); // 1.50
The C-style subset supports %%, %c, %s, signed and unsigned integer
conversions, and decimal or hexadecimal floating-point conversions. 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.
Number locales #
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); // 1234567
Implement 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.
Custom formatters #
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
bool canFormat(Object? value) => value is Map<String, Object?>;
@override
String format(Map<String, Object?> value, FormatOptions options) =>
value.toString();
}
final jsonFormat = Format(formatters: [JsonFormatter()]);
jsonFormat.format('{:json}', <String, Object?>{'answer': 42});
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)],
);
Attribute lookup #
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
bool canLookup(Object? value) => value is 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)}); // 7
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'},
}); // Ada
An [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}]); // throws
Custom representations #
Implement 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
bool canRepresent(Object? value) => value is 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\xa2
Failures inside an extension #
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.
Errors Dart raises on the extension's behalf are wrapped the same way. A
canFormat that accepts a value of the wrong type produces a TypeError when
the engine calls format, and an extension that formats by calling the engine
again on the same value produces a StackOverflowError — both arrive as
FormatExtensionException rather than escaping the engine raw.
JavaScript number semantics #
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.
Representations #
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.
Template cache #
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 all
There 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 nothing
You 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.
When to turn it off #
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 node
tool/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.
Format 3.0 migration #
Version 2.0.0 was never published to pub.dev, so migrating from the published 1.6.0 means adopting the 2.0 and 3.0 changes together; both are described in the CHANGELOG.
Version 3.0 removes 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.
Version 3.0 uses Dart SDK decimal double conversion by 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.