Formats date and time intervals as localized strings.
Interval formats produce strings like "Jan 10 – 12, 2008" from two dates, rather than repeating "Jan 10, 2008 – Jan 12, 2008". The format is selected based on the greatest calendar field difference between the start and end values.
An interval is formatted with the formats of its endpoints' calendar: its interval patterns, its date and time formats, and the date-time pattern joining a date to a time range. Both endpoints must therefore be in the same calendar.
An interval writes the fields its format names and no others, as TR35's interval algorithm has it. Two values alike in every field the format writes are written once; two that differ in a field it writes take CLDR's interval pattern for their greatest difference; and where there is no such pattern, as where they differ in a year, a month or an era the format does not write, each is written with the format around the locale's fallback pattern. An era is a field like any other: format: :GyMMMd writes "Dec 31, 1 BC – Jan 1, 1 AD", and a format without an era writes none.
The two values are written in the order the locale's interval fallback pattern states, which TR35 makes the order of every interval pattern: the earlier first in every locale but one of CLDR 49. kek's Gregorian pattern is "{1} – {0}", so it writes the later value first.
Summary
Functions
Returns the greatest calendar field difference between two dates or datetimes.
Returns the locale-independent skeletons for the :fields option of to_string/3.
Parses a localized date interval.
Splits an interval format string into [left, right] halves
at the point where a format character repeats.
Formats a date, time, or datetime interval into typed parts, mirroring ECMA-402's formatRangeToParts.
Same as to_parts/3 but raises on error.
Formats an interval between two dates, times or datetimes as a localized string.
Same as to_string/3 but raises on error.
Functions
@spec greatest_difference(map(), map()) :: {:ok, :y | :M | :d | :H | :m} | {:error, Exception.t()}
Returns the greatest calendar field difference between two dates or datetimes.
Arguments
fromis a date or datetime map.tois a date or datetime map.
Returns
{:ok, field}where field is:y,:M,:d,:H, or:m.{:error, %Localize.NoPracticalDifferenceError{}}if the values are equal at every field considered.
Examples
iex> Localize.Interval.greatest_difference(~D[2022-04-22], ~D[2022-04-27])
{:ok, :d}
iex> Localize.Interval.greatest_difference(~D[2021-12-31], ~D[2022-01-01])
{:ok, :y}
@spec known_fields() :: %{
month: %{short: :M, medium: :MMM, long: :MMM, full: :MMM},
month_and_day: %{short: :Md, medium: :MMMd, long: :MMMEd, full: :MMMEd},
year_and_month: %{short: :yM, medium: :yMMM, long: :yMMMM, full: :yMMMM}
}
Returns the locale-independent skeletons for the :fields option of to_string/3.
Only the non-default :fields selections (:month,
:month_and_day, :year_and_month) appear here, because only
those are locale-independent. The default :date selection is
resolved per-locale, mirroring Localize.Date.to_string/2's
:format → skeleton mapping for that locale, so it has no fixed
entry to list.
Returns
- A map keyed by field selection (
:month,:month_and_day,:year_and_month), each value a map of:format(:short,:medium,:long,:full) to the CLDR skeleton atom used for that combination.
Examples
iex> Localize.Interval.known_fields()
%{
month: %{short: :M, full: :MMM, long: :MMM, medium: :MMM},
month_and_day: %{short: :Md, full: :MMMEd, long: :MMMEd, medium: :MMMd},
year_and_month: %{short: :yM, full: :yMMMM, long: :yMMMM, medium: :yMMM}
}
@spec parse(String.t() | {String.t(), String.t()}, Keyword.t()) :: {:ok, Date.Range.t() | {map(), map()}} | {:error, Exception.t()}
Parses a localized date interval.
The inverse of to_string/3. Accepts either a single string, such as "May 5 – 10, 2026", or a 2-tuple {from_string, to_string} for two-input UIs that already have the endpoints split.
A single string is read with the locale's interval formats, in which the two dates share the fields written once, and otherwise cut where the locale's fallback pattern or a separator people write, a dash or "to", has a date on each side, each read as Localize.Date.parse/2 reads it. The spaces about an interval format's dash may be left out or put in. Two dates written without a year are of the reference date's year, as a date alone is, and the later is of the year after where it would otherwise come before the earlier: "Dec 28 – Jan 3" ends in the January that follows. Two weeks written without a year are read the same way by their week-based years: "Sun (week: 53) – Mon (week: 1)" ends the day after it begins.
The result is a Date.Range.t/0 whose endpoints share the calendar named by the :calendar option, which defaults to Calendar.ISO.
Arguments
inputis either a binary or a{from_binary, to_binary}tuple.optionsis a keyword list of options.
Options
Same as Localize.Date.parse/2 — :locale, :calendar, :format,
:reference_date, :as. As there, :calendar is a calendar module and
the endpoints are returned in it. :format is the interval's own format, as to_string/3
takes it, and the text is read as two dates are written at it: with the
patterns of the format's interval format, in which the two share the
fields written once ("Jun 16 – 20, 2026" at :medium); as two dates each
written whole with the format, either side of the locale's separator; and
as one date, which two dates alike in every field the format writes are
written as, read as the range of that date alone. No pattern of another
format is tried. Plus:
:allow_invertedis a boolean. Whentrue, an end-before-start interval is returned as-is, sinceDate.range/3builds a descending range. Whenfalse, the default, an inverted interval is rejected with aLocalize.DateRangeParseError.t/0. Applies only whenas: :struct, the default;as: :mapskips the comparison because partial maps may not carry enough fields to compare.
Returns
{:ok, range}, aDate.Range.t/0, whenas: :struct, or{:ok, {from_map, to_map}}whenas: :map. Each endpoint is a field map; missing fields are inherited from the other endpoint per the CLDR interval convention, so"May 5 – May 10, 2026"yields two maps both carrying:year. A field given a value no date has is an error, never inherited, or{:error, exception}, aLocalize.DateParseError.t/0orLocalize.DateRangeParseError.t/0, on failure, or aLocalize.InvalidValueError.t/0if an endpoint is not a string or an option is malformed.
Examples
iex> {:ok, range} = Localize.Interval.parse({"2026-05-05", "2026-05-10"})
iex> {range.first, range.last}
{~D[2026-05-05], ~D[2026-05-10]}
iex> Localize.Interval.parse("May 5 – May 10, 2026", locale: :en, as: :map)
{:ok,
{%{calendar: Calendar.ISO, year: 2026, month: 5, day: 5},
%{calendar: Calendar.ISO, year: 2026, month: 5, day: 10}}}
iex> {:ok, range} = Localize.Interval.parse("May 5, 2026 – May 10, 2026", locale: :en)
iex> {range.first, range.last}
{~D[2026-05-05], ~D[2026-05-10]}
iex> Localize.Interval.parse("May 5–10, 2026", locale: :en)
{:ok, Date.range(~D[2026-05-05], ~D[2026-05-10])}
iex> Localize.Interval.parse("May 5 – 10", locale: :en, reference_date: ~D[2026-01-01])
{:ok, Date.range(~D[2026-05-05], ~D[2026-05-10])}
@spec split_interval(term()) :: {:ok, [String.t()]} | {:error, Exception.t()}
Splits an interval format string into [left, right] halves
at the point where a format character repeats.
Arguments
intervalis an interval format pattern string.
Returns
{:ok, [left, right]}whereleftandrightare the two halves of the interval pattern.{:error, exception}if the pattern is malformed, has no repeating field, or is not a string.
Examples
iex> Localize.Interval.split_interval("MMM d – d")
{:ok, ["MMM d – ", "d"]}
@spec to_parts(map(), map(), Keyword.t()) :: {:ok, [%{type: atom(), value: String.t(), source: atom()}]} | {:error, Exception.t()}
Formats a date, time, or datetime interval into typed parts, mirroring ECMA-402's formatRangeToParts.
The parts concatenate to exactly the string to_string/3 produces with the same options. Every part carries a :source key: :start_range for parts of the interval start, :end_range for parts of the interval end, and :shared for the separators between them. When the endpoints have no practical difference the single formatted value carries source :shared throughout.
Unlike to_string/3, open intervals (a nil endpoint) are not supported — both endpoints are required, matching the JS API.
Arguments
fromis aDate,Time,DateTime,NaiveDateTime, or compatible map for the interval start.tois a value of the same kind for the interval end.optionsis a keyword list of options. Seeto_string/3.
Returns
{:ok, parts}wherepartsis a list of%{type: atom(), value: String.t(), source: atom()}maps.{:error, exception}on failure or when an endpoint isnil.
Examples
iex> Localize.Interval.to_parts(~D[2022-04-22], ~D[2022-04-25], locale: :en)
{:ok,
[
%{type: :month, value: "Apr", source: :start_range},
%{type: :literal, value: " ", source: :start_range},
%{type: :day, value: "22", source: :start_range},
%{type: :literal, value: " – ", source: :shared},
%{type: :day, value: "25", source: :end_range},
%{type: :literal, value: ", ", source: :end_range},
%{type: :year, value: "2022", source: :end_range}
]}
@spec to_parts!(map(), map(), Keyword.t()) :: [ %{type: atom(), value: String.t(), source: atom()} ]
Same as to_parts/3 but raises on error.
Arguments
fromis the interval start.tois the interval end.optionsis a keyword list of options. Seeto_parts/3.
Returns
- A list of
%{type: atom(), value: String.t(), source: atom()}maps.
Raises
- Raises an exception if the interval cannot be decomposed into parts.
Examples
iex> Localize.Interval.to_parts!(~D[2022-04-22], ~D[2022-04-25], locale: :en) |> length()
7
@spec to_string(map() | nil, map() | nil, Keyword.t()) :: {:ok, String.t()} | {:error, Exception.t()}
Formats an interval between two dates, times or datetimes as a localized string.
Arguments
fromis the start of the interval: aDate.t/0,Time.t/0,NaiveDateTime.t/0orDateTime.t/0, or a map with their fields, ornilfor an interval open at its start. A date's map may hold only some of its:year,:monthand:day.tois the end of the interval, of the same kind asfrom, ornilfor an interval open at its end.optionsis a keyword list of options.
Options
:localeis a locale identifier. The default is:en.:formatis a standard format,:short,:medium,:longor:full, a skeleton such as:yMMMEdor:yMMMdHm, or a pattern such as"d MMM y". The default is:medium. A skeleton selects CLDR's interval format for its fields, as CLDR keys them, so it can name a format no standard format reaches. A pattern names no interval format, so both endpoints are formatted with it around the locale's interval fallback pattern. A datetime interval's skeleton is split into its date and time fields, as TR35's interval algorithm separates them: on one day the date is written once and the times as a range where CLDR has an interval format for the time fields, a skeleton of time fields alone writes only the times, and a skeleton of date fields alone formats the dates as a date interval does. CLDR has no interval format with seconds, so at a format that writes them,:medium,:longand:fullamong them, two times of one day are each written whole around the fallback pattern, as the algorithm's last step has it: "Jun 15, 2026, 10:00:00 – Jun 15, 2026, 14:30:00". A date in a calendar of weeks, such asCalendrical.ISOWeek, is written at a standard format in the calendar's own notation, so an interval of its dates is both dates around the fallback pattern: "2026-W25-2 – 2026-W27-1". A skeleton's month is such a date's week and its day the weekday, which no interval format is keyed by, so its interval is both dates in full too: "Tue, week 25 of 2026 – Mon, week 26 of 2026".:date_formatand:time_formatchoose the date and the time half of a datetime interval separately, each a standard format, a skeleton or a pattern.:date_formatis a date interval's format too, and:time_formata time interval's.:fieldsselects which date fields a date interval shows with a standard:format::date(the whole date, the default),:month,:month_and_day, or:year_and_month. Seeknown_fields/0.:styleis the pattern joining a datetime interval's date and time::default, the locale's standard date-time pattern, which TR35 says an interval takes ("June 15, 2026, 10:00 – 14:30"), or:at, the "at time" pattern a single date and time takes by default inLocalize.DateTime.to_string/2. The default is:default.:numeric_date_separatorand:numeric_time_separatorare strings replacing the locale's own separators in the rendered pattern, as they do onLocalize.Date.to_string/2. A separator the pattern merges with neighbouring literal text is left alone — fi's day-differing interval pattern is"d.–d.M.y", whose first separator carries the range dash — so an interval may substitute one endpoint and not the other.
With a standard format the two are independent axes: :fields chooses which fields appear, :format chooses how wide they are rendered. So fields: :year_and_month renders the two months against a single year either way — numerically for format: :short ("1/2022" … "3/2022") and spelled out for format: :long ("January" … "March 2022").
At a standard format an interval's dates and times have the fields of the pattern the single value is written with, at the pattern's widths, so they read as Localize.Date.to_string/2 and Localize.Time.to_string/2 write one alone: vi's short date is "1/4/23" and its interval "1/4/23 – 10/4/23", and a locale whose short time is 24-hour writes a 24-hour range. The order and the text between the fields are those of CLDR's interval format for those fields.
Endpoints that differ in no field the interval shows are formatted once: whole dates in the requested standard format, exactly as Localize.Date.to_string/2 renders them. A date interval's fields are compared as its format writes them, so a week (:yw) or a quarter (:yQQQ) is one field whatever months and days it spans: 15 June to 20 July 2026 is "week 25 of 2026 – week 30 of 2026", and 15 to 17 June "week 25 of 2026". Nothing is added to a skeleton: two dates that differ in a month or a year it does not write are each written with it around the fallback pattern, format: :MMMd from 28 December to 3 January being "Dec 28 – Jan 3", and the same day of two years is that day once, "Jun 15". A date and a time are compared the same way: two days of June at 10:00 are "Jun 2026, 10:00" at :yMMMHm, and at two times of day both are written in full.
Dates that hold only some of their fields, two months or a month and a day each, take at a standard format CLDR's interval format for the fields they hold, as Localize.Date.to_string/2 writes each alone: %{month: 6} to %{month: 8} is "Jun – Aug". One format writes both endpoints, so they must hold the same fields. A skeleton, a :fields selection or a pattern is used as it is given.
Returns
{:ok, formatted_string}on success.{:error, exception}on failure, including endpoints in different calendars and dates that hold different fields.
Examples
iex> Localize.Interval.to_string(~D[2022-04-22], ~D[2022-04-25], locale: :en)
{:ok, "Apr 22 – 25, 2022"}
iex> Localize.Interval.to_string(~D[2026-06-15], ~D[2026-06-18], format: :yMMMEd, locale: :en)
{:ok, "Mon, Jun 15 – Thu, Jun 18, 2026"}
iex> Localize.Interval.to_string(%{month: 6, day: 15}, %{month: 6, day: 20}, locale: :en)
{:ok, "Jun 15 – 20"}
iex> Localize.Interval.to_string(~N[2026-06-15 10:00:00], ~N[2026-06-15 14:30:00],
...> format: :yMMMdHm,
...> locale: :en
...> )
{:ok, "Jun 15, 2026, 10:00 – 14:30"}
iex> Localize.Interval.to_string(~N[2026-06-15 10:00:00], ~N[2026-06-15 14:30:00],
...> format: :Hm,
...> locale: :en
...> )
{:ok, "10:00 – 14:30"}
iex> Localize.Interval.to_string(~N[2026-06-15 10:00:00], ~N[2026-06-15 14:30:00],
...> format: :yMMMdHms,
...> locale: :en
...> )
{:ok, "Jun 15, 2026, 10:00:00 – Jun 15, 2026, 14:30:00"}
Same as to_string/3 but raises on error.
Arguments
Options
See to_string/3 for the supported options.
Returns
The formatted interval as a string.
Raises an exception if the interval cannot be formatted.
Examples
iex> Localize.Interval.to_string!(~D[2022-04-22], ~D[2022-04-25], locale: :en)
"Apr 22 – 25, 2022"
iex> Localize.Interval.to_string!(~D[2022-01-15], ~D[2022-03-20], locale: :en)
"Jan 15 – Mar 20, 2022"