DateTime, Email and JSON beyond String and Int
Sending a date as a plain string looks harmless until two clients expect different formats and the backend ends up duplicating validation. Custom scalars move format and validation into a single, reusable type in the schema, with clear rules for serialization, input parsing, and error cases.
Table of Contents
- 1. Why String and Int aren't enough for DateTime, Email and JSON
- 2. Anatomy of a custom scalar: serialize, parseValue, parseLiteral
- 3. Implementing a DateTime custom scalar
- 4. An Email custom scalar with validation
- 5. A JSON custom scalar for dynamic structures
- 6. Declaring and registering custom scalars in the SDL
- 7. Error handling for invalid scalar values
- 8. Using custom scalars in queries and mutations
- 9. Custom scalars compared to input types
- 10. Summary
- 11. FAQ
1. Why String and Int aren't enough for DateTime, Email and JSON
GraphQL ships with five built-in scalar types out of the box: Int, Float, String, Boolean, and ID. For a date, an email address, or a dynamic JSON structure, none of these are semantically fitting, which is why most schemas end up modeling them as String out of necessity. The problem: String carries no format guarantee. A client can send "2026-08-06", "06.08.2026", or "August 6, 2026", and without custom scalars, every resolver reading the field has to bring its own parsing and its own validation.
This scattered validation logic is the real cost: a bug in one resolver's date parsing stays isolated, while the same bug in another resolver goes unnoticed because both places implemented the rules independently. Custom scalars centralize serialize, parseValue, and parseLiteral in a single place in the schema. Every client validating a query against the schema gets type errors for malformed DateTime or Email values before any resolver is even called.
2. Anatomy of a custom scalar: serialize, parseValue, parseLiteral
Every custom scalar in GraphQL defines three functions that together cover the full lifecycle of a value. serialize() converts the internal PHP value, for example a DateTimeImmutable object, into the output form for the response. parseValue() handles the reverse for variables sent as a JSON payload alongside a query. parseLiteral() handles the same job when the value appears directly in the query text as a literal, such as createdAt: "2026-08-06T10:00:00Z".
This three-way split exists because GraphQL knows two different input paths: values can arrive as part of the query syntax (a literal, as an AST node) or as a separate variables object (already decoded from JSON into a PHP value). A custom scalar that only implements parseValue() works for variables but either throws an error for literals in the query text or, worse, accepts unvalidated values. A correct implementation covers both paths consistently, with identical validation logic in both functions.
3. Implementing a DateTime custom scalar
A DateTime custom scalar should always work internally with DateTimeImmutable and enforce a fixed format externally, usually ISO 8601 in UTC. This rules out clients sending local timezone strings that would need to be interpreted ambiguously on the server. The following implementation shows all three required methods for webonyx/graphql-php.
<?php
declare(strict_types=1);
namespace Mironsoft\GraphQlScalars\Type;
use DateTimeImmutable;
use DateTimeInterface;
use GraphQL\Error\Error;
use GraphQL\Language\AST\Node;
use GraphQL\Language\AST\StringValueNode;
use GraphQL\Type\Definition\ScalarType;
/**
* Custom Scalar for ISO 8601 UTC datetimes, e.g. 2026-08-06T10:00:00Z.
*/
final class DateTimeScalar extends ScalarType
{
public string $name = 'DateTime';
public ?string $description = 'ISO 8601 datetime string in UTC, e.g. 2026-08-06T10:00:00Z.';
/**
* Converts an internal DateTimeInterface value into the ISO 8601 output string.
*
* @param mixed $value Internal value, expected to implement DateTimeInterface
* @return string ISO 8601 formatted datetime
* @throws Error If the value is not a DateTimeInterface instance
*/
public function serialize(mixed $value): string
{
if (!$value instanceof DateTimeInterface) {
throw new Error('DateTime scalar can only serialize DateTimeInterface instances.');
}
return $value->format(DateTimeInterface::ATOM);
}
/**
* Parses a datetime value coming from a GraphQL variable payload.
*
* @param mixed $value Raw variable value, expected to be a string
* @return DateTimeImmutable Parsed datetime
* @throws Error If the value is not a valid ISO 8601 string
*/
public function parseValue(mixed $value): DateTimeImmutable
{
if (!is_string($value)) {
throw new Error('DateTime scalar requires a string value.');
}
return $this->parseIso8601($value);
}
/**
* Parses a datetime literal directly from the query AST.
*
* @param Node $valueNode AST node representing the literal
* @param array<string, mixed>|null $variables Query variables, unused here
* @return DateTimeImmutable Parsed datetime
* @throws Error If the literal is not a string or not valid ISO 8601
*/
public function parseLiteral(Node $valueNode, ?array $variables = null): DateTimeImmutable
{
if (!$valueNode instanceof StringValueNode) {
throw new Error('DateTime scalar literal must be a string.', [$valueNode]);
}
return $this->parseIso8601($valueNode->value);
}
/**
* Shared ISO 8601 parsing and validation logic used by parseValue and parseLiteral.
*
* @param string $raw Raw datetime string
* @return DateTimeImmutable Parsed and validated datetime
* @throws Error If the string cannot be parsed as ISO 8601
*/
private function parseIso8601(string $raw): DateTimeImmutable
{
$parsed = DateTimeImmutable::createFromFormat(DateTimeInterface::ATOM, $raw);
if ($parsed === false) {
throw new Error(sprintf('"%s" is not a valid ISO 8601 datetime.', $raw));
}
return $parsed;
}
}
The key point: parseValue() and parseLiteral() both call the same private parseIso8601() method. This prevents the validation logic for variables and literals from drifting apart, a common mistake in hand-written custom scalars where both paths are implemented independently and develop different tolerances over time.
4. An Email custom scalar with validation
An Email custom scalar ideally uses PHP's built-in filter_var() function with FILTER_VALIDATE_EMAIL, instead of maintaining a custom regex. Email validation via regex is notoriously error-prone because the RFC 5322 standard is considerably more complex than most hand-written patterns account for. Serialization for this scalar is especially simple, since the internal and external values are identical, a string stays a string.
<?php
declare(strict_types=1);
namespace Mironsoft\GraphQlScalars\Type;
use GraphQL\Error\Error;
use GraphQL\Language\AST\Node;
use GraphQL\Language\AST\StringValueNode;
use GraphQL\Type\Definition\ScalarType;
/**
* Custom Scalar for RFC 5322 compliant email addresses.
*/
final class EmailScalar extends ScalarType
{
public string $name = 'Email';
public ?string $description = 'A valid email address, validated against RFC 5322.';
/**
* @param mixed $value Internal value, expected to already be a valid email string
* @return string Unmodified email string
* @throws Error If the value is not a string
*/
public function serialize(mixed $value): string
{
if (!is_string($value)) {
throw new Error('Email scalar can only serialize string values.');
}
return $value;
}
/**
* @param mixed $value Raw variable value
* @return string Validated email address
* @throws Error If the value is not a valid email address
*/
public function parseValue(mixed $value): string
{
return $this->validate($value);
}
/**
* @param Node $valueNode AST node representing the literal
* @param array<string, mixed>|null $variables Query variables, unused here
* @return string Validated email address
* @throws Error If the literal is not a valid email address
*/
public function parseLiteral(Node $valueNode, ?array $variables = null): string
{
if (!$valueNode instanceof StringValueNode) {
throw new Error('Email scalar literal must be a string.', [$valueNode]);
}
return $this->validate($valueNode->value);
}
/**
* @param mixed $value Value to validate as an email address
* @return string Validated email address, unchanged
* @throws Error If validation via FILTER_VALIDATE_EMAIL fails
*/
private function validate(mixed $value): string
{
if (!is_string($value) || filter_var($value, FILTER_VALIDATE_EMAIL) === false) {
throw new Error(sprintf('"%s" is not a valid email address.', (string) $value));
}
return $value;
}
}
5. A JSON custom scalar for dynamic structures
Some fields deliberately carry no fixed structure, for example a metadata field for arbitrary extra attributes, or a configuration structure that differs between object types. Instead of building a rigid GraphQL object type hierarchy for that, a JSON custom scalar is the pragmatic solution. It deliberately gives up type safety inside the structure, but keeps the schema flexible for fields whose shape changes frequently or gets passed through unchanged from external systems.
It's important to use the JSON scalar sparingly: every field typed as JSON loses GraphQL's introspection capabilities, clients can no longer query which sub-fields exist, and tools like GraphiQL cannot offer autocomplete. A JSON custom scalar is therefore the right choice for genuinely dynamic data, not as a shortcut to avoid clean object type design.
<?php
declare(strict_types=1);
namespace Mironsoft\GraphQlScalars\Type;
use GraphQL\Error\Error;
use GraphQL\Language\AST\Node;
use GraphQL\Utils\AST;
use GraphQL\Type\Definition\ScalarType;
/**
* Custom Scalar for arbitrary JSON-serializable values.
*/
final class JsonScalar extends ScalarType
{
public string $name = 'JSON';
public ?string $description = 'Arbitrary JSON-serializable value, no fixed structure enforced.';
/**
* @param mixed $value Internal PHP value, must be JSON-serializable
* @return mixed Unchanged value, passed through to the response encoder
* @throws Error If the value cannot be JSON-encoded
*/
public function serialize(mixed $value): mixed
{
json_encode($value);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new Error('JSON scalar value is not JSON-serializable.');
}
return $value;
}
/**
* @param mixed $value Raw variable value, accepted as is
* @return mixed Unchanged value
*/
public function parseValue(mixed $value): mixed
{
return $value;
}
/**
* @param Node $valueNode AST node representing the literal
* @param array<string, mixed>|null $variables Query variables, forwarded to AST value conversion
* @return mixed Value converted from the AST node
*/
public function parseLiteral(Node $valueNode, ?array $variables = null): mixed
{
// AST::valueFromASTUntyped handles objects, lists, and scalars recursively
return AST::valueFromASTUntyped($valueNode, $variables);
}
}
6. Declaring and registering custom scalars in the SDL
In the Schema Definition Language (SDL), a custom scalar is declared with the scalar keyword, without a field list, since scalars have no sub-fields. The actual behavior from serialize, parseValue, and parseLiteral is registered separately as a PHP class and linked to the SDL type name during schema construction.
scalar DateTime
scalar Email
scalar JSON
type Customer {
id: ID!
email: Email!
createdAt: DateTime!
preferences: JSON
}
input CustomerInput {
email: Email!
preferences: JSON
}
type Mutation {
updateCustomer(id: ID!, input: CustomerInput!): Customer!
}
During registration in PHP, the mapping between SDL type names and scalar classes is typically built through a TypeConfigDecorator, which webonyx/graphql-php calls while parsing the SDL document. Every type name from a scalar declaration is mapped to an instance of the matching custom scalar class, so schema and behavior stay separate but are consistently joined together.
7. Error handling for invalid scalar values
When parseValue() or parseLiteral() throw a GraphQL\Error\Error exception, the execution layer of webonyx/graphql-php catches it and automatically translates it into a structured entry in the response's errors array, including a path to the affected argument. The resolver for the affected mutation is never called in that case, the custom scalar validation runs before the actual business logic.
This behavior is one of the biggest advantages of custom scalars over validation in resolver code: an invalid date or a malformed email address produces a clear, specific error before database access, business logic, or side effects are triggered. When validation only happens in the resolver, there's a risk that parts of the mutation have already executed by the time the error is detected.
8. Using custom scalars in queries and mutations
Custom scalars work symmetrically in both directions: as an output type in queries, where serialize() applies, and as an input type in mutation arguments or input types, where parseValue() or parseLiteral() apply. This symmetry is an important design advantage over separate input and output types, because a single scalar type name can be used consistently throughout the entire schema.
mutation UpdateCustomerProfile {
updateCustomer(
id: "42"
input: {
email: "customer@example.com"
preferences: { newsletter: true, theme: "dark" }
}
) {
id
email
createdAt
preferences
}
}
In this example, the Email custom scalar already validates the email argument during query parsing, the JSON custom scalar passes preferences through unchanged as a nested object, and createdAt gets serialized into ISO 8601 format via the DateTime custom scalar when the response is delivered. From the client's perspective the entire process is transparent, only a different scalar type name is visible in the schema.
9. Custom scalars compared to input types
For structured input with multiple named fields, a GraphQL input type is often the better choice over a custom scalar. The table below shows when each approach makes more sense.
| Criterion | Custom Scalar | Input Type |
|---|---|---|
| Structure known and fixed | Unsuitable | Ideal |
| Introspection of sub-fields | Not possible | Full |
| A single primitive value with a format rule | Ideal | Overkill |
| Dynamic, unknown structure (JSON) | Fitting | Cannot be modeled |
| Reuse across many types | Very good | Possible, but heavier |
The rule of thumb: custom scalars for single values with a clear format rule like DateTime or Email, input types for structured objects with multiple named fields. JSON scalars are the deliberate exception for data whose structure is not known at schema design time or changes too often to type meaningfully.
Mironsoft
GraphQL schema design, type safety, and Magento integration
Want a GraphQL schema with clean, reusable types?
We design custom scalars for date values, email addresses, and dynamic structures that centralize validation and free resolver code from repeated checking logic.
Schema review
Analysis of existing string fields for custom scalar potential
Scalar implementation
serialize, parseValue, and parseLiteral, consistent and tested
Error handling
Structured GraphQL errors instead of scattered resolver validation
10. Summary
Custom scalars solve a recurring problem in GraphQL schemas: values with a clear format rule that neither a generic String nor an Int can model cleanly. The three methods serialize, parseValue, and parseLiteral together cover the full lifecycle of a value, from the database through query execution to the response. DateTime, Email, and JSON are the three most common practical use cases, each with its own validation logic but an identical underlying structure.
The biggest win lies in centralization: validation rules that would otherwise be scattered across dozens of resolvers live in a single place in the schema and apply automatically before resolvers are even called. For structured objects with multiple fields, the input type remains the right choice, custom scalars complement it for single, format-specific values and for deliberately dynamic structures like JSON metadata.
Custom Scalars in GraphQL — Key Takeaways
Three required methods
serialize for output, parseValue for variables, parseLiteral for literals in the query text.
Centralized validation
Invalid values are caught as a GraphQL error before the resolver is called, not first in business logic.
DateTime, Email, JSON
The three most common custom scalars, each with its own but consistent validation logic.
Distinction from input types
Custom scalars for single values with a format rule, input types for structured objects with multiple fields.