Skip to content

Conversation

@kbrabrand
Copy link
Contributor

@kbrabrand kbrabrand commented Dec 8, 2025

Description

Adds support for add/subtract operations where the left operand is a datetime string and the right operand is a number. If a valid datetime string is passed the type is a string, while for any invalid datatime values the type is null. Based on this we return a string-null union.

This fixes the underlying issue reported in sanity-io/sanity#8255 where the generated type for the following query would not be correct:

(dateTime("2025-03-01T00:00:00Z") + 60) > dateTime("2024-03-01T00:00:00Z")

it now yields a boolean-null union, as is correct since an invalid dateTime string would result in null any comparison operation that includes a null operand evaluates to null.

https://linear.app/sanity/issue/CLDX-3609/groq-js-support-datetime-numerical-operations-in-type-evaluator

What to review

The code

Testing

Have tested the whole typegen flow in a local project with both a simple subtraction and a subtraction as part of a comparison operation. Also added a test for the case that failed before, with dateTime(...) - number.

@kbrabrand kbrabrand requested a review from sgulseth December 8, 2025 13:37
Copy link
Contributor Author

kbrabrand commented Dec 8, 2025

Copy link
Member

@sgulseth sgulseth left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a few bugs here:

  1. This is order dependent. dateTime() + 10 vs 10 - dateTime()
  2. Now we would also allow "foo" + 10 which should be null and not string|null.

WDYT about a "sub type" to string, which we don't expose:

diff --git i/src/typeEvaluator/functions.ts w/src/typeEvaluator/functions.ts
index 3a143eb..05bd53e 100644
--- i/src/typeEvaluator/functions.ts
+++ w/src/typeEvaluator/functions.ts
@@ -4,7 +4,7 @@ import {optimizeUnions} from './optimizations'
 import type {Scope} from './scope'
 import {walk} from './typeEvaluate'
 import {createGeoJson, mapNode, nullUnion} from './typeHelpers'
-import type {NullTypeNode, TypeNode} from './types'
+import {type NullTypeNode, STRING_TYPE_DATETIME, type TypeNode} from './types'
 
 function unionWithoutNull(unionTypeNode: TypeNode): TypeNode {
   if (unionTypeNode.type === 'union') {
@@ -237,7 +237,10 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode {
         }
 
         if (arg.type === 'string') {
-          return nullUnion({type: 'string'}) // we don't know wether the string is a valid date or not, so we return a [null, string]-union
+          if (arg.value !== undefined) {
+            return {type: 'string', [STRING_TYPE_DATETIME]: true, value: arg.value} // we don't know wether the string is a valid date or not, so we return a [null, string]-union
+          }
+          return nullUnion({type: 'string', [STRING_TYPE_DATETIME]: true}) // we don't know wether the string is a valid date or not, so we return a [null, string]-union
         }
 
         return {type: 'null'} satisfies NullTypeNode
diff --git i/src/typeEvaluator/typeEvaluate.ts w/src/typeEvaluator/typeEvaluate.ts
index 6ea8d3b..e7953a5 100644
--- i/src/typeEvaluator/typeEvaluate.ts
+++ w/src/typeEvaluator/typeEvaluate.ts
@@ -33,20 +33,21 @@ import {match} from './matching'
 import {optimizeUnions} from './optimizations'
 import {Context, Scope} from './scope'
 import {isFuncCall, mapNode, nullUnion, resolveInline} from './typeHelpers'
-import type {
-  ArrayTypeNode,
-  BooleanTypeNode,
-  Document,
-  NullTypeNode,
-  NumberTypeNode,
-  ObjectAttribute,
-  ObjectTypeNode,
-  PrimitiveTypeNode,
-  Schema,
-  StringTypeNode,
-  TypeNode,
-  UnionTypeNode,
-  UnknownTypeNode,
+import {
+  type ArrayTypeNode,
+  type BooleanTypeNode,
+  type Document,
+  type NullTypeNode,
+  type NumberTypeNode,
+  type ObjectAttribute,
+  type ObjectTypeNode,
+  type PrimitiveTypeNode,
+  type Schema,
+  STRING_TYPE_DATETIME,
+  type StringTypeNode,
+  type TypeNode,
+  type UnionTypeNode,
+  type UnknownTypeNode,
 } from './types'
 
 const $trace = debug('typeEvaluator:evaluate:trace')
@@ -618,7 +619,7 @@ function handleOpCallNode(node: OpCallNode, scope: Scope): TypeNode {
           }
           // datetime + number -> datetime (datetimes are represented as strings)
           // Returns null for non-datetime strings at runtime, so we include null in the union
-          if (left.type === 'string' && right.type === 'number') {
+          if (left.type === 'string' && left[STRING_TYPE_DATETIME] && right.type === 'number') {
             return nullUnion({type: 'string'})
           }
 
@@ -654,8 +655,8 @@ function handleOpCallNode(node: OpCallNode, scope: Scope): TypeNode {
           }
           // datetime - number -> datetime (datetimes are represented as strings)
           // Returns null for non-datetime strings at runtime, so we include null in the union
-          if (left.type === 'string' && right.type === 'number') {
-            return nullUnion({type: 'string'})
+          if (left.type === 'string' && left[STRING_TYPE_DATETIME] && right.type === 'number') {
+            return nullUnion({type: 'string', [STRING_TYPE_DATETIME]: true})
           }
           if (left.type === 'number' && right.type === 'number') {
             return {
diff --git i/src/typeEvaluator/types.ts w/src/typeEvaluator/types.ts
index f69bc0d..d827819 100644
--- i/src/typeEvaluator/types.ts
+++ w/src/typeEvaluator/types.ts
@@ -21,12 +21,16 @@ export interface TypeDeclaration {
 /** A schema consisting of a list of Document or TypeDeclaration items, allowing for complex type definitions. */
 export type Schema = (Document | TypeDeclaration)[]
 
+export const STRING_TYPE_DATETIME = Symbol('groq-js.type.string_datetime')
+
 /** Describes a type node for string values, optionally including a value. If a value is provided it will always be the given string value. */
 export interface StringTypeNode {
   /** can be used to identify the type of the node, in this case it's always 'string' */
   type: 'string'
   /** an optional value of the string, if provided it will always be the given string value */
   value?: string
+
+  [STRING_TYPE_DATETIME]?: true
 }

this adds a STRING_TYPE_DATETIME-symbol which we can then use when checking the +/- ops

Copy link
Contributor Author

kbrabrand commented Dec 8, 2025

@sgulseth The spec defines only dateTime +/- number, the other way around return null. The other bit is 👍 I'll adjust.

@sgulseth
Copy link
Member

sgulseth commented Dec 8, 2025

Interestingly enough, we don't follow the spec here:
image

Copy link
Contributor Author

I guess supporting both makes sense. Support both and updated the spec, then..?

@kbrabrand kbrabrand force-pushed the fix/cldx-3609/support-datetime-numerical-operations branch from 1779b74 to 0da8b30 Compare December 8, 2025 22:34
Copy link
Member

@sgulseth sgulseth left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's go with the spec for now 👍 LGTM!

@kbrabrand kbrabrand merged commit 93e4335 into main Dec 9, 2025
7 checks passed
@kbrabrand kbrabrand deleted the fix/cldx-3609/support-datetime-numerical-operations branch December 9, 2025 09:09
@judofyr
Copy link
Collaborator

judofyr commented Dec 9, 2025

Preferably these types of fixes should be done by adding support for dateTime in typeEvaluateCompare:

const primitives: AnnotatedValue[] = [
. This would give us far better coverage of how it works with all types of operators and that it's it sync with groq-js's implementation.

Copy link
Contributor Author

Preferably these types of fixes should be done by adding support for dateTime in typeEvaluateCompare:

const primitives: AnnotatedValue[] = [
. This would give us far better coverage of how it works with all types of operators and that it's it sync with groq-js's implementation.

Cool. I've added a task for it in linear and will follow up later.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants