GraphQL

Custom Scalars

15 min Lesson 18 of 35

Creating Custom Scalar Types

Scalar types represent primitive leaf values in GraphQL. While GraphQL provides built-in scalars like String, Int, Float, Boolean, and ID, you can create custom scalars for specialized data types like DateTime, Email, URL, and JSON.

Built-in Scalars

GraphQL includes five default scalar types that form the foundation of your schema.

Built-in Scalars:
scalar String   # UTF-8 character sequence
scalar Int      # Signed 32-bit integer
scalar Float    # Signed double-precision floating-point value
scalar Boolean  # true or false
scalar ID       # Unique identifier (serialized as String)

type User {
  id: ID!           # Built-in ID scalar
  name: String!     # Built-in String scalar
  age: Int          # Built-in Int scalar
  rating: Float     # Built-in Float scalar
  isActive: Boolean # Built-in Boolean scalar
}

When to Create Custom Scalars

Custom scalars are useful when you need specific validation, serialization, or parsing logic for a data type that's used across your schema.

Use Cases for Custom Scalars:
  • DateTime: ISO 8601 date/time strings with timezone support
  • Email: Validated email addresses
  • URL: Validated URLs with protocol
  • JSON: Arbitrary JSON objects
  • PhoneNumber: Validated phone numbers
  • Currency: Monetary values with precision
  • UUID: Universally unique identifiers

Declaring Custom Scalars

Custom scalars are declared in your schema just like built-in types.

Schema Declaration:
scalar DateTime
scalar Email
scalar URL
scalar JSON
scalar PhoneNumber

type User {
  id: ID!
  name: String!
  email: Email!           # Custom Email scalar
  website: URL            # Custom URL scalar
  phoneNumber: PhoneNumber
  createdAt: DateTime!    # Custom DateTime scalar
  metadata: JSON          # Custom JSON scalar
}

type Post {
  id: ID!
  title: String!
  publishedAt: DateTime
  author: User!
}

Implementing DateTime Scalar

The DateTime scalar handles date/time parsing, validation, and serialization.

DateTime Scalar Implementation (Node.js):
import { GraphQLScalarType, Kind } from 'graphql';

const DateTimeScalar = new GraphQLScalarType({
  name: 'DateTime',
  description: 'ISO 8601 date-time string (e.g., 2024-01-15T10:30:00Z)',

  // Serialize to client (DB -> Client)
  serialize(value) {
    if (value instanceof Date) {
      return value.toISOString();
    }
    if (typeof value === 'string') {
      return new Date(value).toISOString();
    }
    throw new Error('DateTime must be Date object or ISO string');
  },

  // Parse from client input (Client -> Server)
  parseValue(value) {
    if (typeof value === 'string') {
      const date = new Date(value);
      if (isNaN(date.getTime())) {
        throw new Error('Invalid DateTime format');
      }
      return date;
    }
    throw new Error('DateTime must be a string');
  },

  // Parse from query literal
  parseLiteral(ast) {
    if (ast.kind === Kind.STRING) {
      const date = new Date(ast.value);
      if (isNaN(date.getTime())) {
        throw new Error('Invalid DateTime format');
      }
      return date;
    }
    return null;
  }
});

// Register resolver
const resolvers = {
  DateTime: DateTimeScalar
};
Three Methods Explained:
  • serialize: Converts internal value to client-facing format (response)
  • parseValue: Parses variable input from client (variables in mutations)
  • parseLiteral: Parses inline query literals (hardcoded values in queries)

Implementing Email Scalar

The Email scalar validates email addresses using regex patterns.

Email Scalar Implementation:
import { GraphQLScalarType, Kind } from 'graphql';

const EMAIL_REGEX = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i;

const EmailScalar = new GraphQLScalarType({
  name: 'Email',
  description: 'Valid email address',

  serialize(value) {
    if (typeof value !== 'string') {
      throw new Error('Email must be a string');
    }
    if (!EMAIL_REGEX.test(value)) {
      throw new Error('Invalid email format');
    }
    return value.toLowerCase();
  },

  parseValue(value) {
    if (typeof value !== 'string') {
      throw new Error('Email must be a string');
    }
    if (!EMAIL_REGEX.test(value)) {
      throw new Error('Invalid email format');
    }
    return value.toLowerCase();
  },

  parseLiteral(ast) {
    if (ast.kind === Kind.STRING) {
      if (!EMAIL_REGEX.test(ast.value)) {
        throw new Error('Invalid email format');
      }
      return ast.value.toLowerCase();
    }
    return null;
  }
});

const resolvers = {
  Email: EmailScalar
};

Implementing URL Scalar

URL Scalar Implementation:
import { GraphQLScalarType, Kind } from 'graphql';

const URLScalar = new GraphQLScalarType({
  name: 'URL',
  description: 'Valid URL with protocol',

  serialize(value) {
    try {
      const url = new URL(value);
      return url.href;
    } catch {
      throw new Error('Invalid URL format');
    }
  },

  parseValue(value) {
    try {
      const url = new URL(value);
      // Ensure protocol is http or https
      if (!['http:', 'https:'].includes(url.protocol)) {
        throw new Error('URL must use http or https protocol');
      }
      return url.href;
    } catch {
      throw new Error('Invalid URL format');
    }
  },

  parseLiteral(ast) {
    if (ast.kind === Kind.STRING) {
      try {
        const url = new URL(ast.value);
        if (!['http:', 'https:'].includes(url.protocol)) {
          throw new Error('URL must use http or https protocol');
        }
        return url.href;
      } catch {
        throw new Error('Invalid URL format');
      }
    }
    return null;
  }
});

Implementing JSON Scalar

The JSON scalar allows arbitrary JSON objects as field values.

JSON Scalar Implementation:
import { GraphQLScalarType, Kind } from 'graphql';

const JSONScalar = new GraphQLScalarType({
  name: 'JSON',
  description: 'Arbitrary JSON value',

  serialize(value) {
    return value; // Already a JS object
  },

  parseValue(value) {
    return value; // Client sends JSON
  },

  parseLiteral(ast) {
    switch (ast.kind) {
      case Kind.STRING:
      case Kind.BOOLEAN:
        return ast.value;
      case Kind.INT:
      case Kind.FLOAT:
        return parseFloat(ast.value);
      case Kind.OBJECT:
        return parseObject(ast);
      case Kind.LIST:
        return ast.values.map(n => JSONScalar.parseLiteral(n));
      default:
        return null;
    }
  }
});

function parseObject(ast) {
  const value = Object.create(null);
  ast.fields.forEach(field => {
    value[field.name.value] = JSONScalar.parseLiteral(field.value);
  });
  return value;
}

Using graphql-scalars Library

Instead of implementing scalars from scratch, use the graphql-scalars library which provides production-ready implementations.

Install and Use graphql-scalars:
# Install
npm install graphql-scalars

# Usage
import {
  DateTimeResolver,
  EmailAddressResolver,
  URLResolver,
  JSONResolver,
  PhoneNumberResolver,
  UUIDResolver
} from 'graphql-scalars';

const resolvers = {
  DateTime: DateTimeResolver,
  Email: EmailAddressResolver,
  URL: URLResolver,
  JSON: JSONResolver,
  PhoneNumber: PhoneNumberResolver,
  UUID: UUIDResolver
};
Available Scalars in graphql-scalars:
# Common Scalars
DateTime, Date, Time, Duration
EmailAddress, PhoneNumber, URL
UUID, GUID, HexColorCode, RGB, RGBA
JSON, JSONObject
BigInt, Long, Byte
PositiveInt, NonNegativeInt, NegativeInt
PositiveFloat, NonNegativeFloat, NegativeFloat

# Advanced
MAC, IPv4, IPv6
Port, Latitude, Longitude
PostalCode, Currency
SafeInt, Void

Validation Best Practices

Comprehensive Validation Example:
import { GraphQLScalarType, Kind } from 'graphql';

const PhoneNumberScalar = new GraphQLScalarType({
  name: 'PhoneNumber',
  description: 'International phone number (E.164 format)',

  serialize(value) {
    return validateAndFormat(value);
  },

  parseValue(value) {
    return validateAndFormat(value);
  },

  parseLiteral(ast) {
    if (ast.kind === Kind.STRING) {
      return validateAndFormat(ast.value);
    }
    return null;
  }
});

function validateAndFormat(value) {
  if (typeof value !== 'string') {
    throw new Error('PhoneNumber must be a string');
  }

  // Remove whitespace and formatting
  const cleaned = value.replace(/[\s()-]/g, '');

  // E.164 format: +[country code][number]
  const e164Regex = /^\+[1-9]\d{1,14}$/;

  if (!e164Regex.test(cleaned)) {
    throw new Error(
      'PhoneNumber must be in E.164 format (e.g., +14155552671)'
    );
  }

  return cleaned;
}
Warning: Custom scalars validate on every input/output. Complex validation logic can impact performance. Keep validation efficient and consider caching validation results for frequently used values.
Exercise:
  1. Create a HexColor scalar that validates hex color codes (#RGB or #RRGGBB)
  2. Implement a Currency scalar that stores amounts with 2 decimal precision
  3. Build a Markdown scalar that validates Markdown syntax
  4. Create a Latitude and Longitude scalar with range validation (-90 to 90, -180 to 180)
  5. Use the graphql-scalars library to add DateTime, Email, and URL scalars to your schema