JSCalendar — the JSON representation of calendar data (RFC 8984).
JSCalendar says what iCalendar says — events, tasks, recurrence, participants, time zones — in a form that does not need its own parser. It is the IETF's intended successor to iCalendar, and where iCalendar's line-folding, escaping and property parameters have produced two decades of interoperability bugs, this is a JSON object with named fields.
This library reads and writes that format. It does not schedule, and it does not expand recurrences; it turns documents into structs and back, faithfully.
iex> {:ok, event} = JSCalendar.decode(~s({
...> "@type": "Event",
...> "uid": "review-2026-06",
...> "updated": "2026-06-01T09:00:00Z",
...> "title": "Quarterly review",
...> "start": "2026-06-02T09:00:00",
...> "duration": "PT1H"
...> }))
iex> {event.title, event.start}
{"Quarterly review", ~N[2026-06-02 09:00:00]}Three things it takes seriously
Unknown properties survive. Vendor extensions and later
revisions of the specification both arrive as properties this
library has never heard of. They are kept in each object's extra
map and written back untouched, because a reader that silently drops
what it does not understand corrupts data it was only asked to
relay.
Defaults are not written back. A property equal to its RFC default is omitted on encode — its absence already says the same thing — so a document does not grow every time it is read and rewritten.
No dependencies. Erlang's :json and Elixir's Duration are
both standard library, so nothing is pulled in to parse a document
or read a duration.
What is not here yet
recurrenceOverrides, localizations and custom timeZones are
preserved verbatim rather than parsed into structures — they are
PatchObjects and time zone definitions whose semantics deserve their
own attention rather than a hurried first pass. They round-trip
correctly; they are simply not yet typed.
Summary
Functions
Read a JSCalendar document.
Write a JSCalendar object as a document.
Read a JSCalendar object from an already-decoded map.
Write a JSCalendar object as a JSON-ready map.
Types
@type t() :: JSCalendar.Event.t() | JSCalendar.Task.t() | JSCalendar.Group.t()
Any top-level JSCalendar object.
Functions
Read a JSCalendar document.
The object type is taken from @type, which RFC 8984 makes
mandatory on every object.
Arguments
jsonis a JSCalendar document as a string.
Returns
{:ok, object}— aJSCalendar.Event.t/0,JSCalendar.Task.t/0orJSCalendar.Group.t/0; or{:error, reason}when the document is not valid JSON, carries no recognised@type, or holds a property that does not match its declared type.
Examples
iex> {:ok, task} = JSCalendar.decode(~s({"@type":"Task","uid":"t1","title":"Write it up"}))
iex> task.title
"Write it up"
iex> JSCalendar.decode(~s({"@type":"Sandwich"}))
{:error, {:unknown_type, "Sandwich"}}
iex> JSCalendar.decode("not json")
{:error, :invalid_json}
Write a JSCalendar object as a document.
Arguments
objectis aJSCalendar.Event.t/0,JSCalendar.Task.t/0orJSCalendar.Group.t/0.
Returns
{:ok, json}.
Examples
iex> {:ok, json} = JSCalendar.encode(%JSCalendar.Event{uid: "e1"})
iex> :json.decode(json)["@type"]
"Event"
Read a JSCalendar object from an already-decoded map.
Use this when the document arrived as part of a larger payload — a JMAP response, say — and has been decoded once already.
Arguments
mapis a map with string keys.
Returns
{:ok, object}; or{:error, reason}.
Examples
iex> {:ok, event} = JSCalendar.from_map(%{"@type" => "Event", "uid" => "e1"})
iex> event.uid
"e1"
iex> JSCalendar.from_map(%{"uid" => "e1"})
{:error, :missing_type}
Write a JSCalendar object as a JSON-ready map.
Arguments
objectis aJSCalendar.Event.t/0,JSCalendar.Task.t/0orJSCalendar.Group.t/0.
Returns
- a map with string keys.
Examples
iex> JSCalendar.to_map(%JSCalendar.Event{uid: "e1"}) |> Map.get("uid")
"e1"