# Zod Timezone Validation

[![NPM Downloads](https://img.shields.io/npm/dm/zod-timezone-validation)](https://www.npmjs.com/package/zod-timezone-validation)
[![npm version](https://badge.fury.io/js/zod-timezone-validation.svg)](https://badge.fury.io/js/zod-timezone-validation)
[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-ffdd00?logo=buy-me-a-coffee&logoColor=black)](https://www.buymeacoffee.com/romanjs)
[![GitHub Sponsors](https://img.shields.io/badge/Sponsor-ea4aaa?logo=github-sponsors&logoColor=white)](https://github.com/sponsors/Roman86)


A lightweight and robust Zod schema for validating [IANA](https://www.iana.org/time-zones) timezone strings, with flexible handling of canonical and non-canonical names.

This library leverages the native `Intl` API, ensuring that timezone validation is always up-to-date with the user's environment without needing to bundle large timezone databases.

## Key Features

-   **Three Validation Strategies**:
    -   Strictly canonical names only.
    -   Allow valid, but non-canonical names.
    -   Transform non-canonical names to their canonical equivalents (e.g., `Asia/Calcutta` -> `Asia/Kolkata`).
-   **Factory-based API**: Create isolated schema instances with `createTimezoneSchemas(options?)` — no global state, safe for concurrent use with different configs.
-   **Configurable Canonical Mode**: Choose between runtime-based (`Intl`) or strict IANA canonical names (solves runtime-related discrepancies) for consistent cross-environment behavior.
-   **Custom Mappings**: Override or extend timezone mappings for edge cases.
-   **Zero Dependencies**: Relies only on `zod` (v4+) as a peer dependency.
-   **Lightweight**: No bundled timezone data, uses the environment's native `Intl` API.
-   **Fully Typed**: Written in TypeScript with branded types for extra type safety.

### Exports

-   `createTimezoneSchemas(options?)`: Factory function that returns an object with three schemas:
    -   `CoercedCanonicalTimezoneSchema`: Validates a timezone and **transforms** it to its canonical form (e.g., `US/Eastern` becomes `America/New_York`). Infers branded type `CanonicalTimezone`.
    -   `CanonicalTimezoneSchema`: Ensures a timezone is **strictly** in its canonical form. Rejects valid but non-canonical names. Infers branded type `CanonicalTimezone`.
    -   `TimezoneSchema`: Validates any valid timezone, **allowing both canonical and non-canonical names** without transformation. Infers branded type `Timezone`.
-   `CanonicalMode`: Type for the canonical mode option (`'runtime'` | `'iana'`).
-   `TimezoneSchemaOptions`: Type for the configuration options object.
-   `TimezoneMapping`: Type for a custom mapping tuple `[nonCanonical, canonical]`.
-   `TimezoneSchemas`: Interface describing the object returned by `createTimezoneSchemas`.
-   `CanonicalTimezone`: Branded `string` type for a canonical timezone.
-   `Timezone`: Branded `string` type for any valid timezone.

### ❤️ Enjoying this package? Consider buying me a coffee as a token of appreciation!

[<img src="https://raw.githubusercontent.com/Roman86/assets/main/img/bmc_qr_240.png" alt="Buy Me A Coffee" width="120" height="120">](https://www.buymeacoffee.com/romanjs)


## Installation

```bash
npm install zod-timezone-validation
```

or

```bash
yarn add zod-timezone-validation
```

**Note:** `zod` v4+ is a peer dependency and must be installed in your project.

## Usage

Create your schemas with `createTimezoneSchemas()` and destructure the ones you need:

```typescript
import { createTimezoneSchemas } from 'zod-timezone-validation';

const {
  CoercedCanonicalTimezoneSchema,
  CanonicalTimezoneSchema,
  TimezoneSchema,
} = createTimezoneSchemas();
```

### `CoercedCanonicalTimezoneSchema`

This schema validates that a string is a valid IANA timezone. If a non-canonical name is provided, it automatically transforms it into its canonical equivalent.

```typescript
import { createTimezoneSchemas } from 'zod-timezone-validation';

const { CoercedCanonicalTimezoneSchema } = createTimezoneSchemas();

// Transforms non-canonical to canonical
const result1 = CoercedCanonicalTimezoneSchema.parse('Asia/Calcutta');
console.log(result1); // => 'Asia/Kolkata'

// Keeps canonical names as they are
const result2 = CoercedCanonicalTimezoneSchema.parse('America/New_York');
console.log(result2); // => 'America/New_York'

// Throws an error for invalid timezones
try {
  CoercedCanonicalTimezoneSchema.parse('Invalid/Timezone');
} catch (e) {
  console.error(e); // ZodError
}
```

### `CanonicalTimezoneSchema`

This schema ensures the provided string is a **strictly canonical** IANA timezone name. It will reject any valid but non-canonical names.

```typescript
import { createTimezoneSchemas } from 'zod-timezone-validation';

const { CanonicalTimezoneSchema } = createTimezoneSchemas();

// Accepts canonical names
const result = CanonicalTimezoneSchema.parse('Europe/London');
console.log(result); // => 'Europe/London'

// Rejects non-canonical names
try {
  CanonicalTimezoneSchema.parse('GB'); // 'GB' is a non-canonical alias for 'Europe/London'
} catch (e) {
  console.error(e); // ZodError
}
```

### `TimezoneSchema`

This schema is more lenient and validates that a string is a valid IANA timezone, allowing both canonical and non-canonical names without transformation.

```typescript
import { createTimezoneSchemas } from 'zod-timezone-validation';

const { TimezoneSchema } = createTimezoneSchemas();

// Accepts canonical names
const result1 = TimezoneSchema.parse('Australia/Sydney');
console.log(result1); // => 'Australia/Sydney'

// Also accepts non-canonical names
const result2 = TimezoneSchema.parse('US/Eastern');
console.log(result2); // => 'US/Eastern'

// Throws an error for invalid timezones
try {
  TimezoneSchema.parse('Mars/Olympus_Mons');
} catch (e) {
  console.error(e); // ZodError
}
```

## Configuration

Pass options to `createTimezoneSchemas()` to customize behavior. Each call returns an independent set of schemas, so you can use different configurations side by side.

### Canonical Mode

Different JavaScript runtimes (browsers, Node.js) may disagree on what constitutes a "canonical" timezone name. For example, Chromium-based browsers may consider `Asia/Calcutta` canonical, while Node.js and Firefox use `Asia/Kolkata`.

By default, the library uses strict IANA canonical names for consistent cross-environment behavior. You can opt into runtime mode if you prefer to follow the environment's `Intl` implementation:

```typescript
import { createTimezoneSchemas } from 'zod-timezone-validation';

// Default: strict IANA canonical names (consistent across environments)
const { CoercedCanonicalTimezoneSchema } = createTimezoneSchemas();

// Asia/Calcutta will always become Asia/Kolkata, regardless of the runtime
const result = CoercedCanonicalTimezoneSchema.parse('Asia/Calcutta');
console.log(result); // => 'Asia/Kolkata'

// Opt into runtime's Intl implementation (may vary by environment)
const { CoercedCanonicalTimezoneSchema: RuntimeSchema } = createTimezoneSchemas({
  canonicalMode: 'runtime',
});
```

**Available modes:**
- `'iana'` (default): Uses strict IANA-compliant canonical names (`Intl` with conflicts resolution layer). Provides consistent behavior across all environments.
- `'runtime'`: Uses the runtime's `Intl` implementation. Results may vary between environments.

### Custom Mappings

You can provide custom timezone mappings that override the built-in behavior. Mappings are defined as an array of case-insensitive tuples `[nonCanonicalName, canonicalName]`:

```typescript
import { createTimezoneSchemas } from 'zod-timezone-validation';

const { CoercedCanonicalTimezoneSchema } = createTimezoneSchemas({
  // case-insensitive keys and values
  customMappings: [
    // Force a specific mapping
    ['Asia/Calcutta', 'Asia/Kolkata'],
    // Add a custom alias
    ['my/customzone', 'America/New_York'],
    // Introduce a new timezone name (will be assumed valid and canonical)
    ['legacy/zone', 'My/New/Canonical'],
  ],
});

const result1 = CoercedCanonicalTimezoneSchema.parse('My/CustomZone');
console.log(result1); // => 'America/New_York'

const result2 = CoercedCanonicalTimezoneSchema.parse('Legacy/Zone');
console.log(result2); // => 'My/New/Canonical'
```

**Key features:**
- Keys (non-canonical names) are matched **case-insensitively**.
- Values (canonical names) are treated as valid canonical names. If the value doesn't exist in the built-in canonical sets, it becomes a new valid canonical name.
- Custom mappings take precedence over both IANA and runtime mappings.

### Combining Options

```typescript
import { createTimezoneSchemas } from 'zod-timezone-validation';

const { CoercedCanonicalTimezoneSchema, CanonicalTimezoneSchema } = createTimezoneSchemas({
  canonicalMode: 'runtime',
  customMappings: [
    ['legacy/internal', 'America/Chicago'],
  ],
});
```

## Getting a List of Timezones

While this library focuses on *validating* timezone strings, it doesn't provide an exhaustive list of them. The environment already provides a way to get a list of all supported IANA timezone names through the native `Intl` API.

You can get an array of all available timezones like this:

```typescript
const availableTimezones = Intl.supportedValuesOf('timeZone');

console.log(availableTimezones);
// => ['Africa/Abidjan', 'Africa/Accra', 'Africa/Addis_Ababa', ..., 'Zulu']
```

This approach ensures that you are working with the timezones supported by the user's runtime environment (browser or server), without needing to bundle a large, static list.

## Migration from v1.x

v2 replaces global mutable schemas with a factory function:

```typescript
// Before (v1.x)
import {
  CoercedCanonicalTimezoneSchema,
  CanonicalTimezoneSchema,
  TimezoneSchema,
  configureTimezoneSchema,
} from 'zod-timezone-validation';

configureTimezoneSchema({ canonicalMode: 'iana' });
CoercedCanonicalTimezoneSchema.parse('Asia/Calcutta');

// After (v2)
import { createTimezoneSchemas } from 'zod-timezone-validation';

const { CoercedCanonicalTimezoneSchema } = createTimezoneSchemas();
CoercedCanonicalTimezoneSchema.parse('Asia/Calcutta');
```

**Breaking changes:**
- `configureTimezoneSchema()` is removed — pass options to `createTimezoneSchemas()` instead.
- Schemas are no longer global singletons — each `createTimezoneSchemas()` call returns independent instances.
- Default canonical mode changed from `'runtime'` to `'iana'` for consistent cross-environment behavior.
- Requires `zod` v4+.

## License

This project is licensed under the MIT License.
