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.
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 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 InvalidSpecifierExceptionWidth 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'); // 9Decimal 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. 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:
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. 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 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.
The n presentation type and the ,/_ grouping flags read a NumberLocale.
The default 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 may localize signs, separators, and digits beyond what the C locale core specifies; the compatibility fixtures pin only the C locale behavior.
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.
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 }}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)}); // 7A 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'},
}); // AdaImplement 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\xa2Anything 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.
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. 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: in the package's own benchmark a first call costs roughly two to three times a cached one, depending on the mini-language. 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
templateCacheCharacterLimit; // 1 Mi characters by default, per mini-language
templateCacheSize; // how many parsed templates are resident
templateCacheCharacters; // how much template text they 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 template text: whichever binds first evicts. The unit is characters, which is what you can see; a cached entry holds around 5.5 times that in memory, since the text is reachable from the key, the fragments, the literal nodes and the compiled literals. The default is therefore about 5.7 MiB per mini-language.
A template longer than the whole budget is formatted but never cached โ emptying the cache for one entry 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 nothingLowering 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. Read with templateCacheCharacters, it also tells a cache full of
small templates from one held by a handful of large 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:
cd benchmark/suite
dart run bin/benchmark.dartVersion 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. 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.