diff --git a/.bumpy/timezone-data-type.md b/.bumpy/timezone-data-type.md
new file mode 100644
index 000000000..15af90fa3
--- /dev/null
+++ b/.bumpy/timezone-data-type.md
@@ -0,0 +1,6 @@
+---
+varlock: minor
+env-spec-language: patch
+---
+
+Add built-in `timeZone` data type that validates IANA time zone names. Rename the `simple-object` data type to `simpleObject` so all built-in type names are camelCase (`simple-object` still works but logs a deprecation warning).
diff --git a/packages/varlock-website/src/content/docs/reference/data-types.mdx b/packages/varlock-website/src/content/docs/reference/data-types.mdx
index c6b6ceb3f..c08b62982 100644
--- a/packages/varlock-website/src/content/docs/reference/data-types.mdx
+++ b/packages/varlock-website/src/content/docs/reference/data-types.mdx
@@ -300,6 +300,15 @@ MY_DATE=2024-03-20T15:30:00Z
```
+
+### `timeZone`
+Checks for a valid [IANA time zone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) name, using the time zone database built into the JS runtime. Names are case-sensitive, and UTC offsets like `+05:00` are rejected.
+```env-spec
+# @type=timeZone
+APP_TIME_ZONE=Europe/Madrid
+```
+
+
### `uuid`
Checks for valid [UUID](https://en.wikipedia.org/wiki/UUID) (versions 1-8 per [RFC 9562](https://www.rfc-editor.org/rfc/rfc9562), including `NIL` and `MAX`).
@@ -327,10 +336,10 @@ MY_HASH=d41d8cd98f00b204e9800998ecf8427e
-### `simple-object`
-Validates and coerces JSON strings into objects. Equivalent to a bare [`record`](#record); prefer `record`, which can also validate keys and values.
+### `simpleObject`
+Validates and coerces JSON strings into objects. Equivalent to a bare [`record`](#record); prefer `record`, which can also validate keys and values. Formerly named `simple-object`, which still works but is deprecated.
```env-spec
-# @type=simple-object
+# @type=simpleObject
MY_OBJECT={"key": "value"}
```
@@ -430,6 +439,6 @@ Validation errors are reported per entry (`"apac" is not a valid key - ...`).
In application code, `ENV.ENDPOINTS` is typed as `Record` (enum-constrained keys narrow further). In `process.env` the value is JSON.
-An untyped item whose value is an object literal (e.g. `ITEM={k=v}`) is inferred as a record automatically. The existing `simple-object` type behaves the same as a bare `record`.
+An untyped item whose value is an object literal (e.g. `ITEM={k=v}`) is inferred as a record automatically. The existing `simpleObject` type behaves the same as a bare `record`.
diff --git a/packages/varlock/BUNDLED_ICONS_LICENSES.md b/packages/varlock/BUNDLED_ICONS_LICENSES.md
index a9f196c82..a80b17ff7 100644
--- a/packages/varlock/BUNDLED_ICONS_LICENSES.md
+++ b/packages/varlock/BUNDLED_ICONS_LICENSES.md
@@ -698,7 +698,7 @@ Apache License
- Author: Pictogrammers (https://github.com/Templarian/MaterialDesign)
- License: Apache 2.0 (Apache-2.0), https://github.com/Templarian/MaterialDesign/blob/master/LICENSE
-- Icons used: `mdi:identifier`, `mdi:timer-outline`, `mdi:web`
+- Icons used: `mdi:identifier`, `mdi:map-clock-outline`, `mdi:timer-outline`, `mdi:web`
```
Pictogrammers Free License
diff --git a/packages/varlock/src/env-graph/lib/data-types.ts b/packages/varlock/src/env-graph/lib/data-types.ts
index 3f1aa4fc6..0ef84a7db 100644
--- a/packages/varlock/src/env-graph/lib/data-types.ts
+++ b/packages/varlock/src/env-graph/lib/data-types.ts
@@ -795,7 +795,7 @@ const DomainDataType = createEnvGraphDataType(
const SimpleObjectDataType = createEnvGraphDataType({
- name: 'simple-object',
+ name: 'simpleObject',
icon: 'tabler:code-dots', // curly brackets with nothing inside
coercedType: 'object',
coerce(val) {
@@ -973,6 +973,52 @@ const IsoDateDataType = createEnvGraphDataType({
});
+// Validation leans on the runtime's own tz database via `Intl`, so we ship no zone list.
+// Engines accept a few things that are not IANA names (UTC offsets like `+05:00`) and match
+// names case-insensitively, while most consumers (e.g. the `TZ` env var on Linux) are
+// case-sensitive, so we check both explicitly.
+// NOTE: mirrored in packages/vscode-plugin/src/diagnostics-core.ts - keep them in sync.
+let lowercaseTimeZoneLookup: Map | undefined;
+function findTimeZoneByCase(val: string): string | undefined {
+ if (!lowercaseTimeZoneLookup) {
+ lowercaseTimeZoneLookup = new Map();
+ const zones = (Intl as any).supportedValuesOf?.('timeZone') as Array | undefined;
+ for (const zone of zones ?? []) lowercaseTimeZoneLookup.set(zone.toLowerCase(), zone);
+ }
+ return lowercaseTimeZoneLookup.get(val.toLowerCase());
+}
+/** returns an error message, or undefined if `val` is a correctly cased IANA time zone name */
+function checkTimeZoneName(val: string): string | undefined {
+ if (/^[+-]\d/.test(val)) return 'Value must be an IANA time zone name, not a UTC offset';
+ let resolved: string;
+ try {
+ resolved = new Intl.DateTimeFormat('en-US', { timeZone: val }).resolvedOptions().timeZone;
+ } catch {
+ return `"${val}" is not a known IANA time zone`;
+ }
+ if (resolved === val) return;
+ const correctCase = findTimeZoneByCase(val)
+ ?? (resolved.toLowerCase() === val.toLowerCase() ? resolved : undefined);
+ if (correctCase) {
+ return correctCase === val ? undefined : `Time zone names are case-sensitive, did you mean "${correctCase}"?`;
+ }
+ // A legacy alias (e.g. `US/Eastern`) that the engine canonicalized to another zone. Engines
+ // do not expose alias casing, but every segment of every IANA name starts with an uppercase
+ // letter or digit, so a lowercase start is always wrong.
+ if (val.split('/').some((segment) => /^[a-z]/.test(segment))) {
+ return `Time zone names are case-sensitive, "${val}" is miscased (it is an alias of "${resolved}")`;
+ }
+}
+const TimeZoneDataType = createEnvGraphDataType({
+ name: 'timeZone',
+ icon: 'mdi:map-clock-outline',
+ typeDescription: 'IANA time zone name (e.g. `Europe/Madrid`)',
+ validate(val) {
+ const error = checkTimeZoneName(val);
+ return error ? new ValidationError(error) : true;
+ },
+});
+
// RFC 9562 versions 1-8 (captures the version digit), plus the special NIL and MAX UUIDs
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-([1-8])[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const NIL_UUID = '00000000-0000-0000-0000-000000000000';
@@ -1384,6 +1430,14 @@ const RecordDataType = createEnvGraphDataType((settings?: RecordDataTypeSettings
};
});
+/**
+ * Old type names still accepted (with a deprecation warning) and the name that replaced them.
+ * TODO: drop in the next major.
+ */
+export const DEPRECATED_DATA_TYPE_ALIASES: Record = {
+ 'simple-object': 'simpleObject',
+};
+
export const BaseDataTypes: Array = [
StringDataType,
NumberDataType,
@@ -1397,6 +1451,7 @@ export const BaseDataTypes: Array = [
PortDataType,
SemverDataType,
IsoDateDataType,
+ TimeZoneDataType,
UuidDataType,
Md5DataType,
DurationDataType,
diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts
index c6a89a42d..5a3296279 100644
--- a/packages/varlock/src/env-graph/lib/env-graph.ts
+++ b/packages/varlock/src/env-graph/lib/env-graph.ts
@@ -7,7 +7,7 @@ import { EnvGraphDataSource, FileBasedDataSource, ImportAliasSource } from './da
import { computeFilteredKeys, type ParsedItemFilter } from './item-filter';
import { BaseResolvers, createResolver, type ResolverChildClass } from './resolver';
-import { BaseDataTypes, type EnvGraphDataTypeFactory } from './data-types';
+import { BaseDataTypes, DEPRECATED_DATA_TYPE_ALIASES, type EnvGraphDataTypeFactory } from './data-types';
import { findGraphCycles, getTransitiveDeps, type GraphAdjacencyList } from './graph-utils';
import { ResolutionError, SchemaError, ValidationError } from './errors';
import {
@@ -491,6 +491,9 @@ export class EnvGraph {
for (const dataType of BaseDataTypes) {
this.registerDataType(dataType);
}
+ for (const [alias, name] of Object.entries(DEPRECATED_DATA_TYPE_ALIASES)) {
+ this.dataTypesRegistry[alias] = this.dataTypesRegistry[name];
+ }
// register base resolvers (concat, ref, exec, etc)
for (const resolverClass of BaseResolvers) {
this.registerResolver(resolverClass);
diff --git a/packages/varlock/src/env-graph/lib/type-decorator.ts b/packages/varlock/src/env-graph/lib/type-decorator.ts
index 58fd90ee4..89ac8ca38 100644
--- a/packages/varlock/src/env-graph/lib/type-decorator.ts
+++ b/packages/varlock/src/env-graph/lib/type-decorator.ts
@@ -7,7 +7,9 @@ import {
ParsedEnvSpecStaticValue,
type ParsedEnvSpecDecorator,
} from '@env-spec/parser';
-import type { CoercedType, EnvGraphDataType, EnvGraphDataTypeFactory } from './data-types';
+import {
+ DEPRECATED_DATA_TYPE_ALIASES, type CoercedType, type EnvGraphDataType, type EnvGraphDataTypeFactory,
+} from './data-types';
import {
assertUnwrappedRegexSource, convertParsedValueToResolvers, deprecatedRegexStringWarning,
type Resolver,
@@ -98,10 +100,19 @@ function unknownDataTypeError(name: string, context?: string): SchemaError {
return new SchemaError(`${context ? `${context} - ` : ''}unknown data type: ${name}`, {
// `object` is the natural guess for what varlock calls `record` (reserved for a
// possible future per-key shape type)
- ...name === 'object' ? { tip: 'use record(...) for typed key/value maps, or simple-object for untyped JSON objects' } : {},
+ ...name === 'object' ? { tip: 'use record(...) for typed key/value maps, or simpleObject for untyped JSON objects' } : {},
});
}
+function warnIfDeprecatedTypeName(ctx: TypeSpecContext, name: string, context?: string) {
+ const replacement = DEPRECATED_DATA_TYPE_ALIASES[name];
+ if (!replacement) return;
+ pushWarningOnce(ctx.warnings, new SchemaError(
+ `${context ? `${context} - ` : ''}data type "${name}" is deprecated, use "${replacement}" instead`,
+ { isWarning: true, tip: 'it will be removed in the next major version' },
+ ));
+}
+
function typeSpecDisplayName(node: TypeSpecNode): string {
return node instanceof ParsedEnvSpecFunctionCall ? node.name : String(node.value);
}
@@ -191,6 +202,7 @@ function buildBareTypePlan(
context?: string,
): TypeSpecPlan {
if (!(name in ctx.registry)) throw unknownDataTypeError(name, context);
+ warnIfDeprecatedTypeName(ctx, name, context);
if (name === 'enum') throw emptyEnumError(context ?? '@type=enum');
return { deferred: [], build: () => ctx.registry[name]() };
}
@@ -505,6 +517,7 @@ function buildTypeCallPlan(
): TypeSpecPlan {
const name = fnCall.name;
if (!(name in ctx.registry)) throw unknownDataTypeError(name, context);
+ warnIfDeprecatedTypeName(ctx, name, context);
if (name === 'array') return buildArrayTypePlan(ctx, fnCall, context);
if (name === 'record') return buildRecordTypePlan(ctx, fnCall, context);
diff --git a/packages/varlock/src/env-graph/lib/type-generation/bundled-icons.gen.ts b/packages/varlock/src/env-graph/lib/type-generation/bundled-icons.gen.ts
index 016e2f5ac..472d29a3b 100644
--- a/packages/varlock/src/env-graph/lib/type-generation/bundled-icons.gen.ts
+++ b/packages/varlock/src/env-graph/lib/type-generation/bundled-icons.gen.ts
@@ -12,6 +12,7 @@ export const BUNDLED_ICONS: Record = {
"material-symbols-light:category": "",
"material-symbols:captive-portal": "",
"mdi:identifier": "",
+ "mdi:map-clock-outline": "",
"mdi:timer-outline": "",
"mdi:web": "",
"simple-icons:semver": "",
diff --git a/packages/varlock/src/env-graph/lib/type-generation/bundled-icons/mdi--map-clock-outline.svg b/packages/varlock/src/env-graph/lib/type-generation/bundled-icons/mdi--map-clock-outline.svg
new file mode 100644
index 000000000..42c0fc98b
--- /dev/null
+++ b/packages/varlock/src/env-graph/lib/type-generation/bundled-icons/mdi--map-clock-outline.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/varlock/src/env-graph/test/composite-serialization.test.ts b/packages/varlock/src/env-graph/test/composite-serialization.test.ts
index 4ba172782..cf84a75e6 100644
--- a/packages/varlock/src/env-graph/test/composite-serialization.test.ts
+++ b/packages/varlock/src/env-graph/test/composite-serialization.test.ts
@@ -187,10 +187,10 @@ describe('runtime re-injection of composite values (initVarlockEnv)', () => {
});
});
-describe('simple-object serialization (issue #900)', () => {
- it('simple-object values serialize as JSON, not "[object Object]"', async () => {
+describe('simpleObject serialization (issue #900)', () => {
+ it('simpleObject values serialize as JSON, not "[object Object]"', async () => {
const g = await loadAndResolve(outdent`
- # @type=simple-object
+ # @type=simpleObject
MY_OBJECT='{"key": "value"}'
`);
expect(g.configSchema.MY_OBJECT.resolvedValue).toEqual({ key: 'value' });
diff --git a/packages/varlock/src/env-graph/test/data-types.test.ts b/packages/varlock/src/env-graph/test/data-types.test.ts
index d42e14fe8..6d48b4c25 100644
--- a/packages/varlock/src/env-graph/test/data-types.test.ts
+++ b/packages/varlock/src/env-graph/test/data-types.test.ts
@@ -956,6 +956,77 @@ describe('md5 data type', () => {
});
});
+describe('deprecated data type names', () => {
+ it.each([
+ '@type=simple-object',
+ '@type=array(simple-object)',
+ ])('%s still works but warns', async (decorator) => {
+ const g = await loadAndResolve(outdent`
+ # ${decorator}
+ OBJ='${decorator.includes('array') ? '[{"a": 1}]' : '{"a": 1}'}'
+ `);
+ const item = g.configSchema.OBJ;
+ expect(item.isValid).toBe(true);
+ expect(item.dataType?.name).toBe(decorator.includes('array') ? 'array' : 'simpleObject');
+ const warnings = item.errors.filter((e) => (e as any).isWarning);
+ expect(warnings.map((w) => w.message).join('\n')).toContain('use "simpleObject" instead');
+ });
+});
+
+describe('timeZone data type', () => {
+ it.each([
+ 'Europe/Madrid',
+ 'UTC',
+ 'America/Argentina/Buenos_Aires',
+ 'America/Buenos_Aires', // legacy alias
+ 'US/Eastern', // legacy alias, canonicalized to America/New_York on Node
+ 'Etc/UTC',
+ 'Etc/GMT+5',
+ ])('accepts %s', async (value) => {
+ const g = await loadAndResolve(outdent`
+ # @type=timeZone
+ TZ=${value}
+ `);
+ expect(g.configSchema.TZ.isValid).toBe(true);
+ expect(g.configSchema.TZ.resolvedValue).toBe(value);
+ });
+
+ it.each([
+ ['typo', 'Europe/Madird'],
+ ['UTC offset', '+05:00'],
+ ['compact UTC offset', '-0800'],
+ ['garbage', 'not a zone'],
+ ])('rejects %s', async (_label, value) => {
+ const g = await loadAndResolve(outdent`
+ # @type=timeZone
+ TZ="${value}"
+ `);
+ expect(g.configSchema.TZ.isValid).toBe(false);
+ });
+
+ it.each([
+ ['europe/madrid', 'Europe/Madrid'],
+ ['utc', 'UTC'],
+ ])('rejects wrong casing (%s) and suggests the correct name', async (value, suggestion) => {
+ const g = await loadAndResolve(outdent`
+ # @type=timeZone
+ TZ=${value}
+ `);
+ expect(g.configSchema.TZ.isValid).toBe(false);
+ expect(g.configSchema.TZ.validationErrors?.[0]?.message).toContain(`"${suggestion}"`);
+ });
+
+ // Node canonicalizes these to a different zone, so no casing suggestion is available
+ it.each(['us/eastern', 'etc/utc'])('rejects wrong-cased legacy alias %s', async (value) => {
+ const g = await loadAndResolve(outdent`
+ # @type=timeZone
+ TZ=${value}
+ `);
+ expect(g.configSchema.TZ.isValid).toBe(false);
+ expect(g.configSchema.TZ.validationErrors?.[0]?.message).toContain('case-sensitive');
+ });
+});
+
describe('domain data type', () => {
it('accepts a basic domain', async () => {
const g = await loadAndResolve(outdent`
diff --git a/packages/varlock/src/env-graph/test/type-generation.test.ts b/packages/varlock/src/env-graph/test/type-generation.test.ts
index b3ebc8951..5f460a6ec 100644
--- a/packages/varlock/src/env-graph/test/type-generation.test.ts
+++ b/packages/varlock/src/env-graph/test/type-generation.test.ts
@@ -1396,7 +1396,7 @@ describe('type generation', () => {
FLAG=true # @type=boolean @public
# @type=enum(dev, staging, prod)
APP_ENV=dev # @public
- # @type=simple-object
+ # @type=simpleObject
CONFIG={} # @public
`,
});
diff --git a/packages/varlock/src/env-graph/test/type-generation/helpers.ts b/packages/varlock/src/env-graph/test/type-generation/helpers.ts
index cf4059c07..213209033 100644
--- a/packages/varlock/src/env-graph/test/type-generation/helpers.ts
+++ b/packages/varlock/src/env-graph/test/type-generation/helpers.ts
@@ -20,7 +20,7 @@ export const NON_STRING_TYPE_FIXTURE = outdent`
DEBUG=false # @optional @public
# @type=enum(dev, staging, prod)
APP_ENV=dev # @required @public
- # @type=simple-object
+ # @type=simpleObject
CONFIG={} # @optional @public
API_KEY= # @required @sensitive
`;
diff --git a/packages/vscode-plugin/src/diagnostics-core.ts b/packages/vscode-plugin/src/diagnostics-core.ts
index 3fa00d0e5..fed7c688b 100644
--- a/packages/vscode-plugin/src/diagnostics-core.ts
+++ b/packages/vscode-plugin/src/diagnostics-core.ts
@@ -628,6 +628,32 @@ export function getStaticValueWarning(typeInfo: TypeInfo, value: string) {
return undefined;
}
+// Mirrors the runtime `timeZone` validator in packages/varlock/src/env-graph/lib/data-types.ts.
+let lowercaseTimeZoneLookup: Map | undefined;
+function checkTimeZoneName(value: string): string | undefined {
+ if (/^[+-]\d/.test(value)) return 'Value must be an IANA time zone name, not a UTC offset.';
+ let resolved: string;
+ try {
+ resolved = new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone;
+ } catch {
+ return 'Value must be a known IANA time zone.';
+ }
+ if (resolved === value) return undefined;
+ if (!lowercaseTimeZoneLookup) {
+ const zones = (Intl as any).supportedValuesOf?.('timeZone') as Array | undefined;
+ lowercaseTimeZoneLookup = new Map((zones ?? []).map((zone) => [zone.toLowerCase(), zone]));
+ }
+ const correctCase = lowercaseTimeZoneLookup.get(value.toLowerCase())
+ ?? (resolved.toLowerCase() === value.toLowerCase() ? resolved : undefined);
+ if (correctCase) {
+ return correctCase === value ? undefined : `Time zone names are case-sensitive, did you mean "${correctCase}"?`;
+ }
+ if (value.split('/').some((segment) => /^[a-z]/.test(segment))) {
+ return `Time zone names are case-sensitive, "${value}" is miscased (it is an alias of "${resolved}").`;
+ }
+ return undefined;
+}
+
export function validateStaticValue(typeInfo: TypeInfo, value: string) {
switch (typeInfo.name) {
case 'string':
@@ -676,6 +702,8 @@ export function validateStaticValue(typeInfo: TypeInfo, value: string) {
return /^\d{4}-\d{2}-\d{2}(?:[T ][0-9:.+-Z]*)?$/.test(value) && !Number.isNaN(Date.parse(value))
? undefined
: 'Value must be a valid ISO date.';
+ case 'timeZone':
+ return checkTimeZoneName(value);
case 'uuid': {
const version = typeInfo.options.version;
const detectedVersion = /^[0-9a-f]{8}-[0-9a-f]{4}-([1-8])[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.exec(value)?.[1];
diff --git a/packages/vscode-plugin/src/intellisense-catalog.ts b/packages/vscode-plugin/src/intellisense-catalog.ts
index c443c3017..efafd67ef 100644
--- a/packages/vscode-plugin/src/intellisense-catalog.ts
+++ b/packages/vscode-plugin/src/intellisense-catalog.ts
@@ -370,10 +370,10 @@ export const DATA_TYPES: Array = [
],
},
{
- name: 'simple-object',
+ name: 'simpleObject',
summary: 'JSON-like object value.',
documentation: 'Coerces plain objects or JSON strings into objects.',
- insertText: 'simple-object',
+ insertText: 'simpleObject',
},
{
name: 'enum',
@@ -446,6 +446,12 @@ export const DATA_TYPES: Array = [
documentation: 'Supports date strings with optional time and milliseconds.',
insertText: 'isoDate',
},
+ {
+ name: 'timeZone',
+ summary: 'IANA time zone name.',
+ documentation: 'Validates names like `Europe/Madrid` or `UTC` against the runtime time zone database.',
+ insertText: 'timeZone',
+ },
{
name: 'uuid',
summary: 'UUID string.',
diff --git a/packages/vscode-plugin/test/diagnostics-core.test.ts b/packages/vscode-plugin/test/diagnostics-core.test.ts
index 708566a69..d96ae8b74 100644
--- a/packages/vscode-plugin/test/diagnostics-core.test.ts
+++ b/packages/vscode-plugin/test/diagnostics-core.test.ts
@@ -543,6 +543,15 @@ describe('diagnostics-core', () => {
),
).toBe('Value must be a valid IPv4 address.');
+ const timeZoneType = { name: 'timeZone', args: [], options: {} };
+ expect(validateStaticValue(timeZoneType, 'Europe/Madrid')).toBeUndefined();
+ expect(validateStaticValue(timeZoneType, 'US/Eastern')).toBeUndefined();
+ expect(validateStaticValue(timeZoneType, 'Europe/Madird')).toBe('Value must be a known IANA time zone.');
+ expect(validateStaticValue(timeZoneType, '+05:00')).toBe('Value must be an IANA time zone name, not a UTC offset.');
+ expect(validateStaticValue(timeZoneType, 'europe/madrid')).toBe('Time zone names are case-sensitive, did you mean "Europe/Madrid"?');
+ expect(validateStaticValue(timeZoneType, 'utc')).toBe('Time zone names are case-sensitive, did you mean "UTC"?');
+ expect(validateStaticValue(timeZoneType, 'us/eastern')).toContain('case-sensitive');
+
expect(
validateStaticValue(
{