format 3.0.0 copy "format: ^3.0.0" to clipboard
format: ^3.0.0 copied to clipboard

retracted

Dart string formatting with Python-style braces and printf mini-languages.

format #

CI

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 09 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.

59
likes
0
points
40.6k
downloads

Publisher

verified publisheryet-another.dev

Weekly Downloads

Dart string formatting with Python-style braces and printf mini-languages.

Repository (GitHub)
View/report issues

Topics

#string #formatting #printf #sprintf #interpolation

License

unknown (license)

Dependencies

characters

More

Packages that depend on format