Repository: graphql-rust/juniper (language: rust)

Issue:
Provide support for Jiff date/time types
## Description

This is a proposal to add first-class support for the data types from [Jiff](https://crates.io/crates/jiff). First-class support is necessary, and an external crate would be much more inconvenient to use, because of Rust's orphan rule as outlined in https://github.com/graphql-rust/juniper/issues/87#issuecomment-325918618.

This should cover the following date/time types:

### Timestamps

- [`Timestamp`](https://docs.rs/jiff/0.1.4/jiff/struct.Timestamp.html), serialized as RFC 3339 (ISO 8601) date/time with UTC time zone, GraphQL scalar [`DateTime`](https://the-guild.dev/graphql/scalars/docs/scalars/date-time), e.g. `2024-06-19T19:22:45Z`
- [`Zoned`](https://docs.rs/jiff/0.1.4/jiff/struct.Zoned.html), serialized as RFC 9557 date/time with time zone, GraphQL scalar `ZonedDateTime`[^1], e.g. `2024-07-04T08:39:00-04:00[America/New_York]`

[^1]: This always includes the time zone specifier and uses RFC ~8536~ 9557. This format seems not widely used yet. It is also not a valid input for GraphQL scalar `DateTime`, therefore we serialize it as the GraphQL scalar `ZonedDateTime` which has not been stabilized, or even defined elsewhere, yet.

### Wall-clocked

- [`Date`](https://docs.rs/jiff/0.1.4/jiff/civil/struct.Date.html), serialized as date, without time zone, GraphQL scalar [`LocalDate`](https://the-guild.dev/graphql/scalars/docs/scalars/local-date), e.g. `2024-06-19`
- [`Time`](https://docs.rs/jiff/0.1.4/jiff/civil/struct.Time.html), serialized as time, without time zone, GraphQL scalar [`LocalTime`](https://the-guild.dev/graphql/scalars/docs/scalars/local-time), e.g. `15:22:45`
- [`DateTime`](https://docs.rs/jiff/0.1.4/jiff/civil/struct.DateTime.html), serialized as date/time, without time zone, GraphQL scalar [`LocalDateTime`](https://the-guild.dev/graphql/scalars/docs/scalars/local-date-time), e.g. `2024-06-19T15:22:45`

### Durations

- [`Span`](https://docs.rs/jiff/0.1.4/jiff/struct.Span.html), serialized as ISO 8601 duration, GraphQL scalar [`Duration`](https://the-guild.dev/graphql/scalars/docs/scalars/duration), e.g. `P5dT8h1m`

### Time zones

- [`TimeZone`](https://docs.rs/jiff/0.1.5/jiff/tz/struct.TimeZone.html), serialized as IANA time zone identifier, GraphQL scalar [`TimeZone`](https://the-guild.dev/graphql/scalars/docs/scalars/time-zone), e.g. `America/New_York`

Where types overlap, the resulting serialization would be identical to the one for the [`chrono`](https://crates.io/crates/chrono) and [`time`](https://crates.io/crates/time) crates, with the difference of using GraphQL scalar `LocalDate` instead of [`Date`](https://the-guild.dev/graphql/scalars/docs/scalars/date).[^2]

[^2]: `LocalDate` seems to be the more appropriate type. I am not sure why `chrono` and `time` integration chose to use `Date` instead.

I am aware that Jiff is a very new library but I think it has great potential in the ecosystem. Having dealt with date/time intricacies in several projects, I regard it as one of the top-contenders for following or eventually even superseding `chrono`/`time`.

~I am willing to provide a PR if this proposal is accepted.~ See #1271 for a PR that implements these features.

See #1272 for a PR that adds support for the types `Zoned` and `TimeZone`.


Discussion / hints:
Jiff author here. Great to see this! Happy to answer questions. :-)

For `Zoned`, in order to deserialize into it, a time zone ID like `[America/New_York]` is required. This is from [RFC 9557](https://datatracker.ietf.org/doc/rfc9557/) (not RFC 8536) and extends RFC 3339. You are absolutely correct that it does not yet enjoy broad support. Temporal will support it (but that isn't in browsers yet) and `java.time` supports it. I'm not sure what else does, but hopefully more will support it soon.

With that said, if your datetimes do not have a time zone ID in them, and GraphQL cannot otherwise store them, then probably the right thing to do is to _not_ offer `Zoned` support directly. Either that, or error if the `Zoned` contains a time zone with a non-None [`TimeZone::iana_name`](https://docs.rs/jiff/latest/jiff/tz/struct.TimeZone.html#method.iana_name), since otherwise serializing and deserializing will be lossy.

The idea with a `Zoned` is that if you have a `Zoned` in hand, then you have an assertion about what the local time is in relationship to UTC.

Everything else looks correct to me, including the mapping from Jiff's civil date types to GraphQL's local data types. (With the caveat that I don't know too much about GraphQL. But the docs you linked all seem like they support the mapping you chose here.)
@BurntSushi Wow, didn't expect you to notice this already. Nice to see you here, and thank you so much for making Jiff :slightly_smiling_face:

I can see two potential issues from my putting together a PR for this feature:

1. GraphQL scalar [`LocalTime`](https://the-guild.dev/graphql/scalars/docs/scalars/local-time) officially only supports millisecond-resolution (i.e. only three digits in the sub-second part `.SSS` in `HH:mm[:ss[.SSS]]`). GraphQL scalar [`LocalDateTime`](https://the-guild.dev/graphql/scalars/docs/scalars/local-date-time) does not mention sub-second resolution at all, but I guess it's the same. I assume there would be some support in GraphQL libraries for higher-resolution times but the current `chrono` and `time` integrations in Juniper also limit serialization to three digits, resulting in potential loss of resolution.
2. GraphQL scalar [`DateTime`](https://the-guild.dev/graphql/scalars/docs/scalars/date-time) supports time zones other than `Z` as laid out in RFC 3339: explicitly given offsets, but without time zone names, e.g. `2024-06-19T15:22:00-04:00`. This would allow us to preserve _some_ information from any `Zoned` objects, i.e. at least the offset, in contrast to `Timestamp` which we can only serialize as UTC. Would it be worthwhile to consider serialization of `Zoned` under these circumstances after all?

Both of these lose data when going through GraphQL scalars. I guess we need to decide whether this can be tolerated.
1. I think this should be fine. If GraphQL outright rejects datetimes with precision better than milliseconds, then I think you'll just need to strip anything less than milliseconds from the datetime (possibly via rounding). Otherwise, we can teach `jiff::fmt::temporal::DateTimePrinter` a new knob for controlling output precision.
2. Yeah that's a tough one, but I think that fits into my suggestion. That is, you return an error when trying to serialize a `Zoned` with an IANA time zone. But you specifically allow serializing a `Zoned` when its `TimeZone::iana_name` is none. This permits users to serialize a fixed offset `Zoned`. And if they have an IANA time zone name, they'll need to do an explicit conversion, e.g., `zdt.with_time_zone(TimeZone::fixed(zdt.offset()))`. But the point is that it's explicit and they aren't subjecting themselves to a subtle footgun. And this is a _very_ subtle footgun, because a fixed offset will "just work" in a lot of cases.
> 1. I think this should be fine. If GraphQL outright rejects datetimes with precision better than milliseconds, then I think you'll just need to strip anything less than milliseconds from the datetime (possibly via rounding). Otherwise, we can teach `jiff::fmt::temporal::DateTimePrinter` a new knob for controlling output precision.

In my current draft, I am rounding [manually](https://github.com/sgoll/juniper/blob/1674c876df58568fb2b8aca682d55cdb2d5b3f20/juniper/src/integrations/jiff.rs#L127). The need to handle errors is unfortunate, but since these can happen only for configuration errors (which we can rule out), there should be no problems at runtime.

Regarding the principle decision to not allow sub-millisecond resolution, I have opened an [issue](https://github.com/Urigo/graphql-scalars/issues/2482) upstream.

> 2. Yeah that's a tough one, but I think that fits into my suggestion. That is, you return an error when trying to serialize a `Zoned` with an IANA time zone. But you specifically allow serializing a `Zoned` when its `TimeZone::iana_name` is none. This permits users to serialize a fixed offset `Zoned`. And if they have an IANA time zone name, they'll need to do an explicit conversion, e.g., `zdt.with_time_zone(TimeZone::fixed(zdt.offset()))`. But the point is that it's explicit and they aren't subjecting themselves to a subtle footgun. And this is a _very_ subtle footgun, because a fixed offset will "just work" in a lot of cases.

I am wondering how we would try to document this. It seems rather subtle to force users to make sure to use properly fixed `Zoned` values in their GraphQL schema types. To me, there seems to be a substantial risk involved where developers are not aware of this and forget to put in the appropriate normalization, leading to unexpected errors at runtime since the type gives no indication of whether IANA names were used for the value's `TimeZone` or not.

Thus, I tend to agree with your initial assessment [above](https://github.com/graphql-rust/juniper/issues/1270#issuecomment-2265865082) and would probably indeed _not_ offer serialization for `Zoned`—which is unfortunate, but it still seems better than having to pick between two footguns :neutral_face:
> In my current draft, I am rounding [manually](https://github.com/sgoll/juniper/blob/1674c876df58568fb2b8aca682d55cdb2d5b3f20/juniper/src/integrations/jiff.rs#L127). The need to handle errors is unfortunate, but since these can happen only for configuration errors (which we can rule out), there should be no problems at runtime.

Yeah that works. You could also drop the rounding if you're okay with truncation, which is what the `%.3f` will do.

> Thus, I tend to agree with your initial assessment https://github.com/graphql-rust/juniper/issues/1270#issuecomment-2265865082 and would probably indeed not offer serialization for Zoned—which is unfortunate, but it still seems better than having to pick between two footguns 😐

Holding off on a `Zoned` impl is fine I think. But to push back a little, presuming serialization returns an error, it doesn't seem like it'd be a huge footgun since the caller will get an error if they try to do something wrong. Then they can fix it. It's at least better than the footgun of silently dropping information.
> Yeah that works. You could also drop the rounding if you're okay with truncation, which is what the `%.3f` will do.

I was unable to do this. It seems the format specifier sets only the _minimum_ number of digits but does not limit the _maximum_ number of digits. I opened https://github.com/BurntSushi/jiff/issues/73 for tracking this separately (but I don't think supporting this would be absolutely necessary, so feel free to comment there or even close that issue if this would make formatting too complex).

> Holding off on a `Zoned` impl is fine I think. But to push back a little, presuming serialization returns an error, it doesn't seem like it'd be a huge footgun since the caller will get an error if they try to do something wrong. Then they can fix it. It's at least better than the footgun of silently dropping information.

My worries are that the caller has no control over this. Assume they are running a GraphQL query and (at runtime) the `Zoned` object in question (that is part of the query) has an IANA time zone where it normally has only UTC or fixed offset time zones. Maybe the server database now stores dates differently to track this information.

Then the code would still compile as usual but now querying this attribute in a GraphQL type would suddenly fail.

---

But, taking a step back, this might all come down to what the transport serialization is able to express anyway:

Since we as developers explicitly decide on what target format to use (e.g. GraphQL scalar [`DateTime`](https://the-guild.dev/graphql/scalars/docs/scalars/date-time)), and we know the limitations of that target format, then it would _probably_ be okay to silently drop the IANA time zone information and just serialize the offset, because RFC 3339 does not support IANA specifiers—and neither do the client-side libraries that consume this data, presumably.

In another environment where the client side supports it, developers could then explicitly settle on using an RFC 9557 serializer type instead for any `Zoned` objects.[^1]

[^1]: It seems there doesn't exist such a type in https://github.com/urigo/graphql-scalars at the moment but we could suggest adding this.

This is similar to the restrictions of `LocalTime` where we as developers explicitly decide to serialize into a type that is unable to express sub-millisecond resolution and lose information from the original `civil::Time` object, but this is fine because it is a conscious decision (and must match the client side that has to consume these values).

---

Unfortunately, I am not aware of an easy way to tell Juniper to use different serialization formats for the same underlying type, other than using newtypes or explicitly rolling out custom conversions—which kind of counters the first-class support for these types that we are trying to add in this PR.

To enable an easy upgrade path to RFC 9557 serialization when a matching GraphQL scalar is added, I see two possibilities:

1. Leave out `Zoned` serialization for now, only serialize `Timestamp` into GraphQL scalar `DateTime`. Then, when some new GraphQL scalar _ZonedDateTime_[^2] has been added, we serialize `Zoned` into it.
2. Add `Zoned` serialization to GraphQL scalar `DateTime` now, dropping IANA time zone names in the conversion. Then, add some feature flag `jiff-rfc9557` or similar later to convert into the RFC 9557 scalar type instead.

[^2]: We would need to come up with a meaningful name that captures the differences between RFC 3339 and RFC 9557.
Gotya. Yeah I'm not especially familiar with GraphQL, but I think what you're saying there makes sense.

I think it would be good to leave out `Zoned` for now. Not necessarily because we ought to wait for better GraphQL support, but also just to see what practice demands. If the practical advantages of supporting `Zoned` end up being large enough to outweigh the footgun of dropping the time zone, then it might make sense to add it at that point.
@sgoll 

> 1. Leave out `Zoned` serialization for now, only serialize `Timestamp` into GraphQL scalar `DateTime`. Then, when some new GraphQL scalar _ZonedDateTime_[2](#user-content-fn-2-a16f334093af5d881e7606f038e33448) has been added, we serialize `Zoned` into it.

The fact that https://graphql-scalars.dev/docs/scalars/ collection doesn't provide such a scalar is not a blocker as for me. We just can introduce the `ZonedDateTime` scalar by ourselves and don't bother, as we had done with the `LocalDateTime` scalar before it appeared there, and as we do with some scalars absent there even now (like `BigDecimal`).

So, additional PR is welcome!

Fix patch:
diff --git a/book/src/types/scalars.md b/book/src/types/scalars.md
index 3efd4b651..372300b2d 100644
--- a/book/src/types/scalars.md
+++ b/book/src/types/scalars.md
@@ -385,29 +385,35 @@ mod date_scalar {
 
 [Juniper] provides out-of-the-box [GraphQL scalar][0] implementations for some very common [Rust] crates. The types from these crates will be usable in your schemas automatically after enabling the correspondent self-titled [Cargo feature].
 
-| [Rust] type                 | [GraphQL] scalar  | [Cargo feature]  |
-|-----------------------------|-------------------|------------------|
-| [`bigdecimal::BigDecimal`]  | `BigDecimal`      | [`bigdecimal`]   |
-| [`bson::oid::ObjectId`]     | [`ObjectID`]      | [`bson`]         |
-| [`bson::DateTime`]          | [`DateTime`]      | [`bson`]         |
-| [`chrono::NaiveDate`]       | [`LocalDate`]     | [`chrono`]       |
-| [`chrono::NaiveTime`]       | [`LocalTime`]     | [`chrono`]       |
-| [`chrono::NaiveDateTime`]   | [`LocalDateTime`] | [`chrono`]       |
-| [`chrono::DateTime`]        | [`DateTime`]      | [`chrono`]       |
-| [`chrono_tz::Tz`]           | [`TimeZone`]      | [`chrono-tz`]    |
-| [`rust_decimal::Decimal`]   | `Decimal`         | [`rust_decimal`] |
-| [`jiff::civil::Date`]       | [`LocalDate`]     | [`jiff`]         |
-| [`jiff::civil::Time`]       | [`LocalTime`]     | [`jiff`]         |
-| [`jiff::civil::DateTime`]   | [`LocalDateTime`] | [`jiff`]         |
-| [`jiff::Timestamp`]         | [`DateTime`]      | [`jiff`]         |
-| [`jiff::Span`]              | [`Duration`]      | [`jiff`]         |
-| [`time::Date`]              | [`LocalDate`]     | [`time`]         |
-| [`time::Time`]              | [`LocalTime`]     | [`time`]         |
-| [`time::PrimitiveDateTime`] | [`LocalDateTime`] | [`time`]         |
-| [`time::OffsetDateTime`]    | [`DateTime`]      | [`time`]         |
-| [`time::UtcOffset`]         | [`UtcOffset`]     | [`time`]         |
-| [`url::Url`]                | [`URL`]           | [`url`]          |
-| [`uuid::Uuid`]              | [`UUID`]          | [`uuid`]         |
+| [Rust] type                 | [GraphQL] scalar      | [Cargo feature]  |
+|-----------------------------|-----------------------|------------------|
+| [`bigdecimal::BigDecimal`]  | `BigDecimal`          | [`bigdecimal`]   |
+| [`bson::oid::ObjectId`]     | [`ObjectID`]          | [`bson`]         |
+| [`bson::DateTime`]          | [`DateTime`]          | [`bson`]         |
+| [`chrono::NaiveDate`]       | [`LocalDate`]         | [`chrono`]       |
+| [`chrono::NaiveTime`]       | [`LocalTime`]         | [`chrono`]       |
+| [`chrono::NaiveDateTime`]   | [`LocalDateTime`]     | [`chrono`]       |
+| [`chrono::DateTime`]        | [`DateTime`]          | [`chrono`]       |
+| [`chrono_tz::Tz`]           | [`TimeZone`]          | [`chrono-tz`]    |
+| [`rust_decimal::Decimal`]   | `Decimal`             | [`rust_decimal`] |
+| [`jiff::civil::Date`]       | [`LocalDate`]         | [`jiff`]         |
+| [`jiff::civil::Time`]       | [`LocalTime`]         | [`jiff`]         |
+| [`jiff::civil::DateTime`]   | [`LocalDateTime`]     | [`jiff`]         |
+| [`jiff::Timestamp`]         | [`DateTime`]          | [`jiff`]         |
+| [`jiff::Zoned`]             | `ZonedDateTime`       | [`jiff`]         |
+| [`jiff::tz::TimeZone`]      | `TimeZoneOrUtcOffset` | [`jiff`]         |
+| [`jiff::tz::TimeZone`]      | [`TimeZone`] [^n1]    | [`jiff`]         |
+| [`jiff::tz::Offset`]        | [`UtcOffset`]         | [`jiff`]         |
+| [`jiff::Span`]              | [`Duration`]          | [`jiff`]         |
+| [`time::Date`]              | [`LocalDate`]         | [`time`]         |
+| [`time::Time`]              | [`LocalTime`]         | [`time`]         |
+| [`time::PrimitiveDateTime`] | [`LocalDateTime`]     | [`time`]         |
+| [`time::OffsetDateTime`]    | [`DateTime`]          | [`time`]         |
+| [`time::UtcOffset`]         | [`UtcOffset`]         | [`time`]         |
+| [`url::Url`]                | [`URL`]               | [`url`]          |
+| [`uuid::Uuid`]              | [`UUID`]              | [`uuid`]         |
+
+[^n1]: Conversion supported via newtype [`integrations::jiff::TimeZone`][10].
 
 
 
@@ -425,7 +431,6 @@ mod date_scalar {
 [`chrono-tz`]: https://docs.rs/chrono-tz
 [`chrono_tz::Tz`]: https://docs.rs/chrono-tz/latest/chrono_tz/enum.Tz.html
 [`DateTime`]: https://graphql-scalars.dev/docs/scalars/date-time
-[`Decimal`]: https://docs.rs/rust_decimal/latest/rust_decimal/struct.Decimal.html
 [`Duration`]: https://graphql-scalars.dev/docs/scalars/duration
 [`ID`]: https://spec.graphql.org/October2021#sec-ID
 [`jiff`]: https://docs.rs/jiff
@@ -434,11 +439,15 @@ mod date_scalar {
 [`jiff::civil::Time`]: https://docs.rs/jiff/latest/jiff/civil/struct.Time.html
 [`jiff::Span`]: https://docs.rs/jiff/latest/jiff/struct.Span.html
 [`jiff::Timestamp`]: https://docs.rs/jiff/latest/jiff/struct.Timestamp.html
+[`jiff::tz::Offset`]: https://docs.rs/jiff/latest/jiff/tz/struct.Offset.html
+[`jiff::tz::TimeZone`]: https://docs.rs/jiff/latest/jiff/tz/struct.TimeZone.html
+[`jiff::Zoned`]: https://docs.rs/jiff/latest/jiff/struct.Zoned.html
 [`LocalDate`]: https://graphql-scalars.dev/docs/scalars/local-date
 [`LocalDateTime`]: https://graphql-scalars.dev/docs/scalars/local-date-time
 [`LocalTime`]: https://graphql-scalars.dev/docs/scalars/local-time
 [`ObjectID`]: https://the-guild.dev/graphql/scalars/docs/scalars/object-id
 [`rust_decimal`]: https://docs.rs/rust_decimal
+[`rust_decimal::Decimal`]: https://docs.rs/rust_decimal/latest/rust_decimal/struct.Decimal.html
 [`ScalarValue`]: https://docs.rs/juniper/0.16.1/juniper/trait.ScalarValue.html
 [`serde`]: https://docs.rs/serde
 [`time`]: https://docs.rs/time
@@ -472,3 +481,4 @@ mod date_scalar {
 [7]: https://spec.graphql.org/October2021#sec-Value-Resolution
 [8]: https://docs.rs/juniper/0.16.1/juniper/derive.GraphQLScalar.html
 [9]: https://docs.rs/juniper/0.16.1/juniper/attr.graphql_scalar.html
+[10]: https://docs.rs/juniper/0.16.1/juniper/integrations/jiff/struct.TimeZone.html
diff --git a/juniper/CHANGELOG.md b/juniper/CHANGELOG.md
index 152c816be..6234739db 100644
--- a/juniper/CHANGELOG.md
+++ b/juniper/CHANGELOG.md
@@ -34,11 +34,14 @@ All user visible changes to `juniper` crate will be documented in this file. Thi
 
 ### Added
 
-- [`jiff` crate] integration behind `jiff` [Cargo feature]: ([#1271], [#1270])
+- [`jiff` crate] integration behind `jiff` [Cargo feature]: ([#1271], [#1278], [#1270])
     - `jiff::civil::Date` as `LocalDate` scalar.
     - `jiff::civil::Time` as `LocalTime` scalar.
     - `jiff::civil::DateTime` as `LocalDateTime` scalar. ([#1275])
     - `jiff::Timestamp` as `DateTime` scalar.
+    - `jiff::Zoned` as `ZonedDateTime` scalar.
+    - `jiff::tz::TimeZone` as `TimeZoneOrUtcOffset` scalar.
+    - `jiff::tz::Offset` as `UtcOffset` scalar.
     - `jiff::Span` as `Duration` scalar.
 
 ### Changed
@@ -51,6 +54,8 @@ All user visible changes to `juniper` crate will be documented in this file. Thi
 [#1272]: /../../pull/1272
 [#1275]: /../../pull/1275
 [#1277]: /../../pull/1277
+[#1278]: /../../pull/1278
+[#1279]: /../../pull/1279
 [#1281]: /../../pull/1281
 
 
diff --git a/juniper/Cargo.toml b/juniper/Cargo.toml
index 6f950a6c3..90671e557 100644
--- a/juniper/Cargo.toml
+++ b/juniper/Cargo.toml
@@ -53,7 +53,7 @@ fnv = "1.0.5"
 futures = { version = "0.3.22", features = ["alloc"], default-features = false }
 graphql-parser = { version = "0.4", optional = true }
 indexmap = { version = "2.0", features = ["serde"] }
-jiff = { version = "0.1.5", features = ["alloc"], default-features = false, optional = true }
+jiff = { version = "0.1.5", features = ["std"], default-features = false, optional = true }
 juniper_codegen = { version = "0.16.0", path = "../juniper_codegen" }
 rust_decimal = { version = "1.20", default-features = false, optional = true }
 ryu = { version = "1.0", optional = true }
@@ -78,6 +78,7 @@ void = { version = "1.0.2", optional = true }
 [dev-dependencies]
 bencher = "0.1.2"
 chrono = { version = "0.4.30", features = ["alloc"], default-features = false }
+jiff = { version = "0.1.5", features = ["tzdb-bundle-always"], default-features = false }
 pretty_assertions = "1.0.0"
 serde_json = "1.0.18"
 serial_test = "3.0"
diff --git a/juniper/src/integrations/jiff.rs b/juniper/src/integrations/jiff.rs
index bea0bdaf4..e58f9f3c2 100644
--- a/juniper/src/integrations/jiff.rs
+++ b/juniper/src/integrations/jiff.rs
@@ -2,26 +2,39 @@
 //!
 //! # Supported types
 //!
-//! | Rust type           | Format                | GraphQL scalar        |
-//! |---------------------|-----------------------|-----------------------|
-//! | [`civil::Date`]     | `yyyy-MM-dd`          | [`LocalDate`][s1]     |
-//! | [`civil::Time`]     | `HH:mm[:ss[.SSS]]`    | [`LocalTime`][s2]     |
-//! | [`civil::DateTime`] | `yyyy-MM-ddTHH:mm:ss` | [`LocalDateTime`][s3] |
-//! | [`Timestamp`]       | [RFC 3339] string     | [`DateTime`][s4]      |
-//! | [`Span`]            | [ISO 8601] duration   | [`Duration`][s5]      |
+//! | Rust type                             | Format                      | GraphQL scalar        |
+//! |---------------------------------------|-----------------------------|-----------------------|
+//! | [`civil::Date`]                       | `yyyy-MM-dd`                | [`LocalDate`][s1]     |
+//! | [`civil::Time`]                       | `HH:mm[:ss[.SSS]]`          | [`LocalTime`][s2]     |
+//! | [`civil::DateTime`]                   | `yyyy-MM-ddTHH:mm:ss`       | [`LocalDateTime`][s3] |
+//! | [`Timestamp`]                         | [RFC 3339] string           | [`DateTime`][s4]      |
+//! | [`Zoned`][^1]                         | [RFC 9557] string           | `ZonedDateTime`       |
+//! | [`tz::TimeZone`][^1]                  | [IANA database][1]/`±hh:mm` | `TimeZoneOrUtcOffset` |
+//! | [`tz::TimeZone`] via [`TimeZone`][^1] | [IANA database][1]          | [`TimeZone`][s5]      |
+//! | [`tz::Offset`]                        | `±hh:mm`                    | [`UtcOffset`][s6]     |
+//! | [`Span`]                              | [ISO 8601] duration         | [`Duration`][s7]      |
 //!
-//! # Unsupported types
+//! [^1]: For these, crate [`jiff`] must be installed with a feature flag that provides access to
+//! the Time Zone Database (e.g. by using the crate's default feature flags). See [`jiff` time zone
+//! features][tz] for details.
 //!
-//! [`Zoned`] is not supported because the GraphQL scalar [`DateTime`][s4] only supports time zone
-//! offsets but no IANA time zone names (as in `2024-08-10T23:14:00-04:00[America/New_York]`, cf.
-//! [RFC 9557]). Serializing such values would incur a loss of information with unexpected and
-//! subtle consequences (a fixed offset would only _seem_ to work in most cases).
+//! # Time zone types
+//!
+//! `tz::TimeZone` values can be IANA time zone identifiers or fixed offsets, corresponding to
+//! GraphQL scalars [`TimeZone`][s5] and [`UtcOffset`][s6]. While `UtcOffset` can be serialized from
+//! [`tz::Offset`] directly, newtype [`TimeZone`] handles serialization to `TimeZone`, with
+//! [`TryFrom`] and [`Into`] implementations from and to `tz::TimeZone`.
+//!
+//! In addition, `tz::TimeZone` serializes to `TimeZoneOrUtcOffset` which is a GraphQL scalar that
+//! contains either an IANA identifier or a fixed offset for clients that can consume both values.
 //!
 //! [`civil::Date`]: jiff::civil::Date
 //! [`civil::DateTime`]: jiff::civil::DateTime
 //! [`civil::Time`]: jiff::civil::Time
 //! [`Span`]: jiff::Span
 //! [`Timestamp`]: jiff::Timestamp
+//! [`tz::Offset`]: jiff::tz::Offset
+//! [`tz::TimeZone`]: jiff::tz::TimeZone
 //! [`Zoned`]: jiff::Zoned
 //! [ISO 8601]: https://en.wikipedia.org/wiki/ISO_8601#Durations
 //! [RFC 3339]: https://datatracker.ietf.org/doc/html/rfc3339#section-5.6
@@ -30,7 +43,13 @@
 //! [s2]: https://graphql-scalars.dev/docs/scalars/local-time
 //! [s3]: https://graphql-scalars.dev/docs/scalars/local-date-time
 //! [s4]: https://graphql-scalars.dev/docs/scalars/date-time
-//! [s5]: https://graphql-scalars.dev/docs/scalars/duration
+//! [s5]: https://graphql-scalars.dev/docs/scalars/time-zone
+//! [s6]: https://graphql-scalars.dev/docs/scalars/utc-offset
+//! [s7]: https://graphql-scalars.dev/docs/scalars/duration
+//! [tz]: https://docs.rs/jiff/latest/jiff/index.html#time-zone-features
+//! [1]: http://www.iana.org/time-zones
+
+use std::{error::Error, fmt, str};
 
 use crate::{graphql_scalar, InputValue, ScalarValue, Value};
 
@@ -242,6 +261,53 @@ mod date_time {
     }
 }
 
+/// Time zone aware instant in time.
+///
+/// Can be thought of as combination of the following types, all rolled into one:
+///
+/// - [`Timestamp`][3] for indicating precise instant in time.
+/// - [`DateTime`][4] for indicating "civil" calendar date and clock time.
+/// - [`TimeZone`][5] for indicating how to apply time zone transitions while performing arithmetic.
+///
+/// [RFC 9557][1] compliant.
+///
+/// See also [`jiff::Zoned`][2] for details.
+///
+/// [1]: https://datatracker.ietf.org/doc/html/rfc9557#section-4.1
+/// [2]: https://docs.rs/jiff/latest/jiff/struct.Zoned.html
+/// [3]: https://docs.rs/jiff/latest/jiff/struct.Timestamp.html
+/// [4]: https://docs.rs/jiff/latest/jiff/civil/struct.DateTime.html
+/// [5]: https://docs.rs/jiff/latest/jiff/tz/struct.TimeZone.html
+#[graphql_scalar(
+    with = zoned_date_time,
+    parse_token(String),
+)]
+pub type ZonedDateTime = jiff::Zoned;
+
+mod zoned_date_time {
+    use std::str::FromStr as _;
+
+    use super::*;
+
+    pub(super) fn to_output<S>(v: &ZonedDateTime) -> Value<S>
+    where
+        S: ScalarValue,
+    {
+        Value::scalar(v.to_string())
+    }
+
+    pub(super) fn from_input<S>(v: &InputValue<S>) -> Result<ZonedDateTime, String>
+    where
+        S: ScalarValue,
+    {
+        v.as_string_value()
+            .ok_or_else(|| format!("Expected `String`, found: {v}"))
+            .and_then(|s| {
+                ZonedDateTime::from_str(s).map_err(|e| format!("Invalid `ZonedDateTime`: {e}"))
+            })
+    }
+}
+
 /// Span of time represented via a mixture of calendar and clock units.
 ///
 /// Represents a duration of time in units of years, months, weeks, days, hours, minutes, seconds,
@@ -282,6 +348,231 @@ mod duration {
     }
 }
 
+/// Representation of time zone or UTC offset.
+///
+/// [IANA database][1] or `±hh:mm`.
+///
+/// See also [`jiff::tz::TimeZone`][2] for details.
+///
+/// [1]: http://www.iana.org/time-zones
+/// [2]: https://docs.rs/jiff/latest/jiff/tz/struct.TimeZone.html
+#[graphql_scalar(
+    with = time_zone_or_utc_offset,
+    parse_token(String),
+)]
+pub type TimeZoneOrUtcOffset = jiff::tz::TimeZone;
+
+mod time_zone_or_utc_offset {
+    use super::*;
+
+    /// Format of a `TimeZoneOrUtcOffset` scalar.
+    const FORMAT: &str = "%:V";
+
+    pub(super) fn to_output<S>(v: &TimeZoneOrUtcOffset) -> Value<S>
+    where
+        S: ScalarValue,
+    {
+        Value::scalar(v.iana_name().map_or_else(
+            || {
+                // If no IANA time zone identifier is available, fall back to displaying the time
+                // offset directly (using format `[+-]HH:MM[:SS]` from RFC 9557, e.g. `+05:30`).
+                //
+                // <https://github.com/graphql-rust/juniper/pull/1278#discussion_r1719161686>
+                jiff::Zoned::now()
+                    .with_time_zone(v.clone())
+                    .strftime(FORMAT)
+                    .to_string()
+            },
+            ToOwned::to_owned,
+        ))
+    }
+
+    pub(super) fn from_input<S>(v: &InputValue<S>) -> Result<TimeZoneOrUtcOffset, String>
+    where
+        S: ScalarValue,
+    {
+        v.as_string_value()
+            .ok_or_else(|| format!("Expected `String`, found: {v}"))
+            .and_then(|s| {
+                TimeZoneOrUtcOffset::get(s)
+                    .map_err(TimeZoneError::InvalidTimeZone)
+                    .or_else(|_| utc_offset::utc_offset_from_str(s).map(TimeZoneOrUtcOffset::fixed))
+                    .map_err(|e| format!("Invalid `TimeZoneOrUtcOffset`: {e}"))
+            })
+    }
+}
+
+/// Error while handling [`TimeZone`] value.
+#[derive(Clone)]
+pub enum TimeZoneError {
+    /// Identifier could not be parsed by [`tz::TimeZone::get`](jiff::tz::TimeZone::get).
+    InvalidTimeZone(jiff::Error),
+    /// GraphQL scalar [`TimeZone`] requires `tz::TimeZone` with IANA name.
+    MissingIanaName(jiff::tz::TimeZone),
+}
+
+impl fmt::Debug for TimeZoneError {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            Self::InvalidTimeZone(err) => write!(f, "TimeZoneError::InvalidTimeZone({err:?})"),
+            Self::MissingIanaName(_value) => write!(f, "TimeZoneError::MissingIanaName(..)"),
+        }
+    }
+}
+
+impl fmt::Display for TimeZoneError {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            Self::InvalidTimeZone(err) => err.fmt(f),
+            Self::MissingIanaName(_value) => write!(f, "missing IANA name"),
+        }
+    }
+}
+
+impl Error for TimeZoneError {
+    fn source(&self) -> Option<&(dyn Error + 'static)> {
+        match self {
+            Self::InvalidTimeZone(err) => Some(err),
+            Self::MissingIanaName(_) => None,
+        }
+    }
+}
+
+/// Representation of time zone.
+///
+/// A set of rules for determining the civil time, via an offset from UTC, in a particular
+/// geographic region. In many cases, the offset in a particular time zone can vary over the course
+/// of a year through transitions into and out of daylight saving time.
+///
+/// [`TimeZone` scalar][1] compliant.
+///
+/// See also [`jiff::tz::TimeZone`][2] for details.
+///
+/// [1]: https://graphql-scalars.dev/docs/scalars/time-zone
+/// [2]: https://docs.rs/jiff/latest/jiff/tz/struct.TimeZone.html
+#[graphql_scalar(
+    with = time_zone,
+    parse_token(String),
+    specified_by_url = "https://graphql-scalars.dev/docs/scalars/time-zone",
+)]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct TimeZone(jiff::tz::TimeZone);
+
+impl TryFrom<jiff::tz::TimeZone> for TimeZone {
+    type Error = TimeZoneError;
+
+    fn try_from(value: jiff::tz::TimeZone) -> Result<Self, Self::Error> {
+        if value.iana_name().is_none() {
+            return Err(TimeZoneError::MissingIanaName(value));
+        }
+        Ok(Self(value))
+    }
+}
+
+impl str::FromStr for TimeZone {
+    type Err = TimeZoneError;
+
+    fn from_str(value: &str) -> Result<Self, Self::Err> {
+        let value = jiff::tz::TimeZone::get(value).map_err(TimeZoneError::InvalidTimeZone)?;
+        value.try_into()
+    }
+}
+
+impl fmt::Display for TimeZone {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        self.0
+            .iana_name()
+            // PANIC: We made sure that IANA name is available when constructing `Self`.
+            .unwrap_or_else(|| panic!("Failed to display `TimeZone`: no IANA name"))
+            .fmt(f)
+    }
+}
+
+impl From<TimeZone> for jiff::tz::TimeZone {
+    fn from(value: TimeZone) -> Self {
+        value.0
+    }
+}
+
+mod time_zone {
+    use std::str::FromStr as _;
+
+    use super::*;
+
+    pub(super) fn to_output<S>(v: &TimeZone) -> Value<S>
+    where
+        S: ScalarValue,
+    {
+        Value::scalar(v.to_string())
+    }
+
+    pub(super) fn from_input<S>(v: &InputValue<S>) -> Result<TimeZone, String>
+    where
+        S: ScalarValue,
+    {
+        v.as_string_value()
+            .ok_or_else(|| format!("Expected `String`, found: {v}"))
+            .and_then(|s| TimeZone::from_str(s).map_err(|e| format!("Invalid `TimeZone`: {e}")))
+    }
+}
+
+/// Represents fixed time zone offset.
+///
+/// [`UtcOffset` scalar][1] compliant.
+///
+/// See also [`jiff::tz::Offset`][2] for details.
+///
+/// [1]: https://graphql-scalars.dev/docs/scalars/utc-offset
+/// [2]: https://docs.rs/jiff/latest/jiff/tz/struct.Offset.html
+#[graphql_scalar(
+    with = utc_offset,
+    parse_token(String),
+    specified_by_url = "https://graphql-scalars.dev/docs/scalars/utc-offset",
+)]
+pub type UtcOffset = jiff::tz::Offset;
+
+mod utc_offset {
+    use super::*;
+
+    /// Format of a [`UtcOffset` scalar][1].
+    ///
+    /// [1]: https://graphql-scalars.dev/docs/scalars/utc-offset
+    const FORMAT: &str = "%:z";
+
+    pub(super) fn utc_offset_from_str(value: &str) -> Result<jiff::tz::Offset, jiff::Error> {
+        let tm = jiff::fmt::strtime::BrokenDownTime::parse(FORMAT, value)?;
+        let offset = tm
+            .offset()
+            .expect("successful %:z parsing guarantees offset");
+        Ok(offset)
+    }
+
+    fn utc_offset_to_string(value: jiff::tz::Offset) -> String {
+        let mut buf = String::new();
+        let tm = jiff::fmt::strtime::BrokenDownTime::from(
+            &jiff::Zoned::now().with_time_zone(jiff::tz::TimeZone::fixed(value)),
+        );
+        tm.format(FORMAT, &mut buf).unwrap();
+        buf
+    }
+
+    pub(super) fn to_output<S>(v: &UtcOffset) -> Value<S>
+    where
+        S: ScalarValue,
+    {
+        Value::scalar(utc_offset_to_string(*v))
+    }
+
+    pub(super) fn from_input<S>(v: &InputValue<S>) -> Result<UtcOffset, String>
+    where
+        S: ScalarValue,
+    {
+        v.as_string_value()
+            .ok_or_else(|| format!("Expected `String`, found: {v}"))
+            .and_then(|s| utc_offset_from_str(s).map_err(|e| format!("Invalid `UtcOffset`: {e}")))
+    }
+}
+
 #[cfg(test)]
 mod local_date_test {
     use crate::{graphql_input_value, FromInputValue as _, InputValue, ToInputValue as _};
@@ -640,6 +931,192 @@ mod date_time_test {
     }
 }
 
+#[cfg(test)]
+mod zoned_date_time_test {
+    use jiff::{civil, tz, tz::TimeZone};
+
+    use crate::{graphql_input_value, FromInputValue as _, InputValue, ToInputValue as _};
+
+    use super::ZonedDateTime;
+
+    #[test]
+    fn parses_correct_input() {
+        for (raw, expected) in [
+            (
+                "2014-11-28T21:00:09+09:00[Asia/Tokyo]",
+                civil::DateTime::constant(2014, 11, 28, 21, 0, 9, 0)
+                    .to_zoned(TimeZone::get("Asia/Tokyo").unwrap())
+                    .unwrap(),
+            ),
+            (
+                "2014-11-28T21:00:09[America/New_York]",
+                civil::DateTime::constant(2014, 11, 28, 21, 0, 9, 0)
+                    .to_zoned(TimeZone::get("America/New_York").unwrap())
+                    .unwrap(),
+            ),
+            (
+                "2014-11-28 21:00:09[America/New_York]",
+                civil::DateTime::constant(2014, 11, 28, 21, 0, 9, 0)
+                    .to_zoned(TimeZone::get("America/New_York").unwrap())
+                    .unwrap(),
+            ),
+            (
+                "2014-11-28T21:00:09Z[gmt+0]",
+                civil::DateTime::constant(2014, 11, 28, 21, 0, 9, 0)
+                    .to_zoned(TimeZone::get("GMT+0").unwrap())
+                    .unwrap(),
+            ),
+            (
+                "2014-11-28T21:00:09+03:00[etc/gmt-3]",
+                civil::DateTime::constant(2014, 11, 28, 21, 0, 9, 0)
+                    .to_zoned(TimeZone::get("Etc/GMT-3").unwrap())
+                    .unwrap(),
+            ),
+            (
+                "2014-11-28T21:00:09+00:00[UTC]",
+                civil::DateTime::constant(2014, 11, 28, 21, 0, 9, 0)
+                    .to_zoned(TimeZone::get("UTC").unwrap())
+                    .unwrap(),
+            ),
+            (
+                "2014-11-28T21:00:09+02:00[+02:00]",
+                civil::DateTime::constant(2014, 11, 28, 21, 0, 9, 0)
+                    .to_zoned(TimeZone::fixed(tz::offset(2)))
+                    .unwrap(),
+            ),
+            (
+                "2014-11-28T21:00:09-11:00[-11:00]",
+                civil::DateTime::constant(2014, 11, 28, 21, 0, 9, 0)
+                    .to_zoned(TimeZone::fixed(tz::offset(-11)))
+                    .unwrap(),
+            ),
+            (
+                "2014-11-28T21:00:09.05+09:00[Asia/Tokyo]",
+                civil::DateTime::constant(2014, 11, 28, 21, 0, 9, 50_000_000)
+                    .to_zoned(TimeZone::get("Asia/Tokyo").unwrap())
+                    .unwrap(),
+            ),
+            (
+                "2014-11-28 21:00:09.05+09:00[Asia/Tokyo]",
+                civil::DateTime::constant(2014, 11, 28, 21, 0, 9, 50_000_000)
+                    .to_zoned(TimeZone::get("Asia/Tokyo").unwrap())
+                    .unwrap(),
+            ),
+        ] {
+            let input: InputValue = graphql_input_value!((raw));
+            let parsed = ZonedDateTime::from_input_value(&input);
+
+            assert!(
+                parsed.is_ok(),
+                "failed to parse `{raw}`: {:?}",
+                parsed.unwrap_err(),
+            );
+            assert_eq!(parsed.unwrap(), expected, "input: {raw}");
+        }
+    }
+
+    #[test]
+    fn fails_on_invalid_input() {
+        for input in [
+            graphql_input_value!("12"),
+            graphql_input_value!("12:"),
+            graphql_input_value!("56:34:22"),
+            graphql_input_value!("56:34:22.000"),
+            graphql_input_value!("1996-12-1914:23:43"),
+            graphql_input_value!("1996-12-19Q14:23:43Z"),
+            graphql_input_value!("1996-12-19T14:23:43"),
+            graphql_input_value!("1996-12-19T14:23:43ZZ"),
+            graphql_input_value!("1996-12-19T14:23:43.543"),
+            graphql_input_value!("1996-12-19T14:23"),
+            graphql_input_value!("1996-12-19T14:23:1"),
+            graphql_input_value!("1996-12-19T14:23:"),
+            graphql_input_value!("1996-12-19T23:78:43Z"),
+            graphql_input_value!("1996-12-19T23:18:99Z"),
+            graphql_input_value!("1996-12-19T24:00:00Z"),
+            graphql_input_value!("1996-12-19T99:02:13Z"),
+            graphql_input_value!("1996-12-19T99:02:13Z"),
+            graphql_input_value!("1996-12-19T12:02:13+4444444"),
+            graphql_input_value!("i'm not even a datetime"),
+            graphql_input_value!("2014-11-28T21:00:09Z"),
+            graphql_input_value!("2014-11-28T21:00:09+09:00"),
+            graphql_input_value!("2014-11-28T21:00:09+09:00[InvTZ]"),
+            graphql_input_value!(2.32),
+            graphql_input_value!(1),
+            graphql_input_value!(null),
+            graphql_input_value!(false),
+        ] {
+            let input: InputValue = input;
+            let parsed = ZonedDateTime::from_input_value(&input);
+
+            assert!(parsed.is_err(), "allows input: {input:?}");
+        }
+    }
+
+    #[test]
+    fn formats_correctly() {
+        for (val, expected) in [
+            (
+                civil::DateTime::constant(1996, 12, 19, 0, 0, 0, 0)
+                    .to_zoned(TimeZone::get("America/New_York").unwrap())
+                    .unwrap(),
+                graphql_input_value!("1996-12-19T00:00:00-05:00[America/New_York]"),
+            ),
+            (
+                civil::DateTime::constant(1964, 7, 30, 5, 0, 0, 123_000_000)
+                    .to_zoned(TimeZone::get("America/New_York").unwrap())
+                    .unwrap(),
+                graphql_input_value!("1964-07-30T05:00:00.123-04:00[America/New_York]"),
+            ),
+            (
+                civil::DateTime::constant(2014, 11, 28, 21, 0, 9, 0)
+                    .to_zoned(TimeZone::get("GMT+0").unwrap())
+                    .unwrap(),
+                graphql_input_value!("2014-11-28T21:00:09+00:00[GMT+0]"),
+            ),
+            (
+                civil::DateTime::constant(2014, 11, 28, 21, 0, 9, 0)
+                    .to_zoned(TimeZone::get("Etc/GMT+3").unwrap())
+                    .unwrap(),
+                graphql_input_value!("2014-11-28T21:00:09-03:00[Etc/GMT+3]"),
+            ),
+            (
+                civil::DateTime::constant(2014, 11, 28, 21, 0, 9, 0)
+                    .to_zoned(TimeZone::get("UTC").unwrap())
+                    .unwrap(),
+                graphql_input_value!("2014-11-28T21:00:09+00:00[UTC]"),
+            ),
+            (
+                civil::DateTime::constant(2014, 11, 28, 21, 0, 9, 0)
+                    .to_zoned(TimeZone::UTC)
+                    .unwrap(),
+                graphql_input_value!("2014-11-28T21:00:09+00:00[UTC]"),
+            ),
+            (
+                civil::DateTime::constant(2014, 11, 28, 21, 0, 9, 0)
+                    .to_zoned(TimeZone::fixed(tz::offset(0)))
+                    .unwrap(),
+                graphql_input_value!("2014-11-28T21:00:09+00:00[UTC]"),
+            ),
+            (
+                civil::DateTime::constant(2014, 11, 28, 21, 0, 9, 0)
+                    .to_zoned(TimeZone::fixed(tz::offset(2)))
+                    .unwrap(),
+                graphql_input_value!("2014-11-28T21:00:09+02:00[+02:00]"),
+            ),
+            (
+                civil::DateTime::constant(2014, 11, 28, 21, 0, 9, 0)
+                    .to_zoned(TimeZone::fixed(tz::offset(-11)))
+                    .unwrap(),
+                graphql_input_value!("2014-11-28T21:00:09-11:00[-11:00]"),
+            ),
+        ] {
+            let actual: InputValue = val.to_input_value();
+
+            assert_eq!(actual, expected, "on value: {val}");
+        }
+    }
+}
+
 #[cfg(test)]
 mod duration_test {
     use jiff::ToSpan as _;
@@ -735,9 +1212,300 @@ mod duration_test {
     }
 }
 
+#[cfg(test)]
+mod time_zone_or_utc_offset_test {
+    use jiff::tz;
+
+    use crate::{graphql_input_value, FromInputValue as _, InputValue, ToInputValue as _};
+
+    use super::TimeZoneOrUtcOffset;
+
+    #[test]
+    fn parses_correct_input() {
+        for (raw, expected) in [
+            (
+                "Europe/London",
+                TimeZoneOrUtcOffset::get("Europe/London").unwrap(),
+            ),
+            ("Etc/GMT-3", TimeZoneOrUtcOffset::get("Etc/GMT-3").unwrap()),
+            (
+                "etc/gmt+11",
+                TimeZoneOrUtcOffset::get("Etc/GMT+11").unwrap(),
+            ),
+            ("factory", TimeZoneOrUtcOffset::get("Factory").unwrap()),
+            ("zULU", TimeZoneOrUtcOffset::get("Zulu").unwrap()),
+            ("UTC", TimeZoneOrUtcOffset::get("UTC").unwrap()),
+            (
+                "+00:00",
+                TimeZoneOrUtcOffset::try_from(tz::TimeZone::fixed(tz::offset(0))).unwrap(),
+            ),
+            (
+                "+03:00",
+                TimeZoneOrUtcOffset::try_from(tz::TimeZone::fixed(tz::offset(3))).unwrap(),
+            ),
+            (
+                "-09:00",
+                TimeZoneOrUtcOffset::try_from(tz::TimeZone::fixed(tz::offset(-9))).unwrap(),
+            ),
+        ] {
+            let input: InputValue = graphql_input_value!((raw));
+            let parsed = TimeZoneOrUtcOffset::from_input_value(&input);
+
+            assert!(
+                parsed.is_ok(),
+                "failed to parse `{raw}`: {:?}",
+                parsed.unwrap_err(),
+            );
+            assert_eq!(parsed.unwrap(), expected, "input: {raw}");
+        }
+    }
+
+    #[test]
+    fn fails_on_invalid_input() {
+        for input in [
+            graphql_input_value!("Abc/Xyz"),
+            graphql_input_value!("8086"),
+            graphql_input_value!("AbcXyz"),
+            graphql_input_value!("Z"),
+            graphql_input_value!("i'm not even a time zone"),
+            graphql_input_value!(2.32),
+            graphql_input_value!(1),
+            graphql_input_value!(null),
+            graphql_input_value!(false),
+        ] {
+            let input: InputValue = input;
+            let parsed = TimeZoneOrUtcOffset::from_input_value(&input);
+
+            assert!(parsed.is_err(), "allows input: {input:?}");
+        }
+    }
+
+    #[test]
+    fn formats_correctly() {
+        for (val, expected) in [
+            (
+                TimeZoneOrUtcOffset::get("Europe/London").unwrap(),
+                graphql_input_value!("Europe/London"),
+            ),
+            (
+                TimeZoneOrUtcOffset::get("Etc/GMT-3").unwrap(),
+                graphql_input_value!("Etc/GMT-3"),
+            ),
+            (
+                TimeZoneOrUtcOffset::get("etc/gmt+11").unwrap(),
+                graphql_input_value!("Etc/GMT+11"),
+            ),
+            (
+                TimeZoneOrUtcOffset::get("Factory").unwrap(),
+                graphql_input_value!("Factory"),
+            ),
+            (
+                TimeZoneOrUtcOffset::get("zulu").unwrap(),
+                graphql_input_value!("Zulu"),
+            ),
+            (
+                TimeZoneOrUtcOffset::fixed(tz::offset(0)),
+                graphql_input_value!("UTC"),
+            ),
+            (
+                TimeZoneOrUtcOffset::get("UTC").unwrap(),
+                graphql_input_value!("UTC"),
+            ),
+            (TimeZoneOrUtcOffset::UTC, graphql_input_value!("UTC")),
+            (
+                TimeZoneOrUtcOffset::fixed(tz::offset(2)),
+                graphql_input_value!("+02:00"),
+            ),
+            (
+                TimeZoneOrUtcOffset::fixed(tz::offset(-11)),
+                graphql_input_value!("-11:00"),
+            ),
+        ] {
+            let actual: InputValue = val.to_input_value();
+
+            assert_eq!(actual, expected, "on value: {val:?}");
+        }
+    }
+}
+
+#[cfg(test)]
+mod time_zone_test {
+    use jiff::tz;
+
+    use crate::{graphql_input_value, FromInputValue as _, InputValue, ToInputValue as _};
+
+    use super::TimeZone;
+
+    #[test]
+    fn parses_correct_input() {
+        for (raw, expected) in [
+            (
+                "Europe/London",
+                TimeZone::try_from(tz::TimeZone::get("Europe/London").unwrap()).unwrap(),
+            ),
+            (
+                "Etc/GMT-3",
+                TimeZone::try_from(tz::TimeZone::get("Etc/GMT-3").unwrap()).unwrap(),
+            ),
+            (
+                "etc/gmt+11",
+                TimeZone::try_from(tz::TimeZone::get("Etc/GMT+11").unwrap()).unwrap(),
+            ),
+            (
+                "factory",
+                TimeZone::try_from(tz::TimeZone::get("Factory").unwrap()).unwrap(),
+            ),
+            (
+                "zULU",
+                TimeZone::try_from(tz::TimeZone::get("Zulu").unwrap()).unwrap(),
+            ),
+            (
+                "UTC",
+                TimeZone::try_from(tz::TimeZone::get("UTC").unwrap()).unwrap(),
+            ),
+        ] {
+            let input: InputValue = graphql_input_value!((raw));
+            let parsed = TimeZone::from_input_value(&input);
+
+            assert!(
+                parsed.is_ok(),
+                "failed to parse `{raw}`: {:?}",
+                parsed.unwrap_err(),
+            );
+            assert_eq!(parsed.unwrap(), expected, "input: {raw}");
+        }
+    }
+
+    #[test]
+    fn fails_on_invalid_input() {
+        for input in [
+            graphql_input_value!("Abc/Xyz"),
+            graphql_input_value!("8086"),
+            graphql_input_value!("AbcXyz"),
+            graphql_input_value!("-02:00"),
+            graphql_input_value!("+11:00"),
+            graphql_input_value!("Z"),
+            graphql_input_value!("i'm not even a time zone"),
+            graphql_input_value!(2.32),
+            graphql_input_value!(1),
+            graphql_input_value!(null),
+            graphql_input_value!(false),
+        ] {
+            let input: InputValue = input;
+            let parsed = TimeZone::from_input_value(&input);
+
+            assert!(parsed.is_err(), "allows input: {input:?}");
+        }
+    }
+
+    #[test]
+    fn formats_correctly() {
+        for (val, expected) in [
+            (
+                TimeZone::try_from(tz::TimeZone::get("Europe/London").unwrap()).unwrap(),
+                graphql_input_value!("Europe/London"),
+            ),
+            (
+                TimeZone::try_from(tz::TimeZone::get("Etc/GMT-3").unwrap()).unwrap(),
+                graphql_input_value!("Etc/GMT-3"),
+            ),
+            (
+                TimeZone::try_from(tz::TimeZone::get("etc/gmt+11").unwrap()).unwrap(),
+                graphql_input_value!("Etc/GMT+11"),
+            ),
+            (
+                TimeZone::try_from(tz::TimeZone::get("Factory").unwrap()).unwrap(),
+                graphql_input_value!("Factory"),
+            ),
+            (
+                TimeZone::try_from(tz::TimeZone::get("zulu").unwrap()).unwrap(),
+                graphql_input_value!("Zulu"),
+            ),
+            (
+                TimeZone::try_from(tz::TimeZone::fixed(tz::offset(0))).unwrap(),
+                graphql_input_value!("UTC"),
+            ),
+            (
+                TimeZone::try_from(tz::TimeZone::get("UTC").unwrap()).unwrap(),
+                graphql_input_value!("UTC"),
+            ),
+            (
+                TimeZone::try_from(tz::TimeZone::UTC).unwrap(),
+                graphql_input_value!("UTC"),
+            ),
+        ] {
+            let actual: InputValue = val.to_input_value();
+
+            assert_eq!(actual, expected, "on value: {val:?}");
+        }
+    }
+}
+
+#[cfg(test)]
+mod utc_offset_test {
+    use jiff::tz;
+
+    use crate::{graphql_input_value, FromInputValue as _, InputValue, ToInputValue as _};
+
+    use super::UtcOffset;
+
+    #[test]
+    fn parses_correct_input() {
+        for (raw, expected) in [
+            ("+00:00", tz::offset(0)),
+            ("+03:00", tz::offset(3)),
+            ("-09:00", tz::offset(-9)),
+        ] {
+            let input: InputValue = graphql_input_value!((raw));
+            let parsed = UtcOffset::from_input_value(&input);
+
+            assert!(
+                parsed.is_ok(),
+                "failed to parse `{raw}`: {:?}",
+                parsed.unwrap_err(),
+            );
+            assert_eq!(parsed.unwrap(), expected, "input: {raw}");
+        }
+    }
+
+    #[test]
+    fn fails_on_invalid_input() {
+        for input in [
+            graphql_input_value!("Europe/London"),
+            graphql_input_value!("Abc/Xyz"),
+            graphql_input_value!("8086"),
+            graphql_input_value!("AbcXyz"),
+            graphql_input_value!("Z"),
+            graphql_input_value!("i'm not even a time zone"),
+            graphql_input_value!(2.32),
+            graphql_input_value!(1),
+            graphql_input_value!(null),
+            graphql_input_value!(false),
+        ] {
+            let input: InputValue = input;
+            let parsed = UtcOffset::from_input_value(&input);
+
+            assert!(parsed.is_err(), "allows input: {input:?}");
+        }
+    }
+
+    #[test]
+    fn formats_correctly() {
+        for (val, expected) in [
+            (tz::offset(0), graphql_input_value!("+00:00")),
+            (tz::offset(2), graphql_input_value!("+02:00")),
+            (tz::offset(-11), graphql_input_value!("-11:00")),
+        ] {
+            let actual: InputValue = val.to_input_value();
+
+            assert_eq!(actual, expected, "on value: {val:?}");
+        }
+    }
+}
+
 #[cfg(test)]
 mod integration_test {
-    use jiff::{civil, tz::TimeZone, ToSpan as _};
+    use jiff::{civil, tz, ToSpan as _};
 
     use crate::{
         execute, graphql_object, graphql_value, graphql_vars,
@@ -745,7 +1513,9 @@ mod integration_test {
         types::scalars::{EmptyMutation, EmptySubscription},
     };
 
-    use super::{DateTime, Duration, LocalDate, LocalDateTime, LocalTime};
+    use super::{
+        DateTime, Duration, LocalDate, LocalDateTime, LocalTime, TimeZone, UtcOffset, ZonedDateTime,
+    };
 
     #[tokio::test]
     async fn serializes() {
@@ -767,11 +1537,25 @@ mod integration_test {
 
             fn date_time() -> DateTime {
                 civil::DateTime::constant(2014, 11, 28, 12, 0, 9, 50_000_000)
-                    .to_zoned(TimeZone::UTC)
+                    .to_zoned(tz::TimeZone::UTC)
                     .unwrap()
                     .timestamp()
             }
 
+            fn zoned_date_time() -> ZonedDateTime {
+                civil::DateTime::constant(2014, 11, 28, 12, 0, 9, 50_000_000)
+                    .to_zoned(tz::TimeZone::get("America/New_York").unwrap())
+                    .unwrap()
+            }
+
+            fn time_zone() -> TimeZone {
+                tz::TimeZone::get("Asia/Tokyo").unwrap().try_into().unwrap()
+            }
+
+            fn utc_offset() -> UtcOffset {
+                tz::offset(10)
+            }
+
             fn duration() -> Duration {
                 1.year()
                     .months(1)
@@ -788,6 +1572,9 @@ mod integration_test {
             localTime
             localDateTime
             dateTime,
+            zonedDateTime,
+            timeZone,
+            utcOffset,
             duration,
         }"#;
 
@@ -805,6 +1592,9 @@ mod integration_test {
                     "localTime": "16:07:08",
                     "localDateTime": "2016-07-08T09:10:11",
                     "dateTime": "2014-11-28T12:00:09.05Z",
+                    "zonedDateTime": "2014-11-28T12:00:09.05-05:00[America/New_York]",
+                    "timeZone": "Asia/Tokyo",
+                    "utcOffset": "+10:00",
                     "duration": "P1y1m1dT1h1m1.1s",
                 }),
                 vec![],


Test patch: