Type System

Grove uses a nominal type system with a fixed set of scalar types, parametric composite types, and user-defined named types. The type system is designed to be expressive enough for domain modeling while remaining simple enough that every type maps cleanly to database columns and serialization formats.

Prerequisites: Lexical Grammar for identifier and literal rules. What you'll learn: All built-in types, composite type constructors, optional types, nominal typing rules, and Value type boundary behavior.


Type Expression Grammar

TypeExpr     = ScalarType
             | CompositeType
             | OptionalType
             | NamedType .

ScalarType   = "Int" | "Decimal" | "Bool" | "String" | "Id"
             | "Date" | "Time" | "DateTime" | "Duration" | "Epoch"
             | "Value" | "Blob" .

CompositeType = ListType | MapType | InlineObjectType .
ListType      = "List" "<" TypeExpr ">" .
MapType       = "Map" "<" TypeExpr "," TypeExpr ">" .
InlineObjectType = "{" { FieldName ":" TypeExpr } "}" .

OptionalType = TypeExpr "?" .
NamedType    = Identifier .

Scalar Types

Int

A 64-bit signed integer. Range: -9,223,372,036,854,775,808 to 9,223,372,036,854,775,807.

let count: Int = 42
let negative: Int = -7

Decimal

An arbitrary-precision decimal number, suitable for financial calculations. Grove Decimals never suffer from floating-point rounding errors.

let price: Decimal = 19.99
let tax_rate: Decimal = 0.0825

Bool

A boolean value: true or false.

let active: Bool = true

String

A UTF-8 encoded string of arbitrary length. Supports interpolation with ${}.

let greeting: String = "Hello, ${name}!"

Id

A prefixed, validated unique identifier. The prefix comes from the record's kind declaration (see Declarations — The kind declaration), and the suffix is a globally unique value (KSUID by default, 27 characters, base62-encoded).

Short form (within a project):

{kind}_{suffix}

Example: post_2wNBB8TuBRfOQxMmCQJpTfqDjRi.

Long form (across projects):

{project}:{kind}_{suffix}

Example: acme:post_2wNBB8TuBRfOQxMmCQJpTfqDjRi. Use long form when referencing a record in a different Grove project. Short form always resolves within the current project.

Format rules:

Generating and parsing:

let order_id: Id = Id.generate()
let known_id: Id = Id.of("post_2wNBB8TuBRfOQxMmCQJpTfqDjRi")

Id.generate() produces a new short-form ID using the enclosing record's kind. Id.of(string) validates a provided string and returns it as an Id; it fails at compile or validation time if the string does not match either the short or long form.

Cross-Project IDs

A project can reference a record that lives in another Grove project by storing the long form. The project: prefix is parsed by the runtime and routes queries to the correct project's database. Within a single project, short form is preferred for readability and storage efficiency.

// Record in project "acme":
record {
  kind "post"
  title: String
  // Reference to a user in a different project:
  author: Id      // stored as "users:user_..." (long form)
}

Date

A calendar date without time zone information (year, month, day).

let today: Date = Date.today()
let specific: Date = Date.of(2025, 3, 15)

Time

A time of day without date or time zone information (hour, minute, second, nanosecond).

let now_time: Time = Time.now()
let specific: Time = Time.of(14, 30, 0)

DateTime

A combined date and time in UTC. This is the recommended type for timestamps.

let now: DateTime = DateTime.now()

Duration

A span of time, expressed as a combination of hours, minutes, seconds, and sub-second precision. Useful for timeouts, delays, and intervals.

let timeout: Duration = Duration.seconds(30)
let interval: Duration = Duration.minutes(5)

Epoch

A Unix timestamp (milliseconds since 1970-01-01T00:00:00Z). Useful for interop with external systems that use epoch-based timestamps.

let ts: Epoch = Epoch.now()
let specific: Epoch = Epoch.of(1700000000000)

Value

A dynamically-typed value that can hold any JSON-compatible data. Use sparingly; prefer strongly-typed alternatives. See Value Type Boundary Behavior for important restrictions.

let metadata: Value = { "source": "import", "version": 2 }
let raw: Value = external_api.get_response()

Blob

A binary large object, represented as a byte array. Used for file contents, images, and other binary data.

let file_content: Blob = storage.read("document.pdf")

Composite Types

List<T>

An ordered, immutable collection of elements of type T.

let numbers: List<Int> = [1, 2, 3]
let names: List<String> = ["Alice", "Bob"]
let empty: List<Decimal> = []

Lists support method chains for transformation and querying. See Expression Grammar for the full method list.

Map<K, V>

An unordered collection of key-value pairs. Keys must be String or Id.

let headers: Map<String, String> = {
  "Content-Type": "application/json",
  "Authorization": "Bearer token123"
}
let scores: Map<String, Int> = { "alice": 95, "bob": 87 }

Inline Object Types

Anonymous structural types used in function signatures and intermediate computations. They are defined inline and do not have a named identity.

function summarize(order: { total: Decimal, item_count: Int }) -> String {
  return "${order.item_count} items, total: ${order.total}"
}

Inline object types are structurally typed (unlike named records, which are nominally typed).

Optional Types

Any type can be made optional by appending ?. An optional type T? can hold either a value of type T or null.

let nickname: String? = null
let address: Address? = customer.shipping_address

Working with Optionals

Use optional chaining (?.), null coalescing (??), and is null / is not null checks to work with optional values:

// Optional chaining
let city = customer.address?.city

// Null coalescing
let display_name = user.nickname ?? user.name

// Null narrowing
if customer.email is not null {
  send_notification(customer.email)  // email is String here, not String?
}

Nested optionals are flattened: T?? is equivalent to T?.

Named Types

Named types are introduced by type, enum, and record declarations. They follow nominal typing rules.

Type Aliases

type Money = Decimal
type UserId = Id

Type aliases create a new name for an existing type. The alias is interchangeable with the original type (structural equivalence for aliases).

Enums

enum Color { Red, Green, Blue }

Enum types are distinct from one another and from all other types. Variants are accessed as Color.Red.

Records

record Point { x: Decimal, y: Decimal }

Record types are nominal: two records with identical fields but different names are distinct types.

Nominal Typing

Grove uses nominal typing for records and enums. Two types are compatible only if they have the same name and originate from the same declaration:

record Meters { value: Decimal }
record Feet { value: Decimal }

// These are DIFFERENT types, even though they have the same structure.
// Assigning a Meters value to a Feet variable is a type error.

Type aliases, by contrast, are structural: type Money = Decimal makes Money and Decimal freely interchangeable.

Value Type Boundary Behavior

The Value type provides an escape hatch for dynamic data but is subject to important restrictions:

  1. No field access without assertion. You cannot directly access fields on a Value. You must first assert it into a known type or use the Value access methods.

  2. Serialization boundary. Value data is serialized as JSON. Types that cannot be represented in JSON (such as Blob, Duration, Date, Time) are converted to their string representation when stored inside a Value.

  3. No type narrowing. Unlike optional types, Value does not participate in type narrowing through is checks in if conditions. Use explicit conversion methods instead.

  4. Event field restriction. Event fields of type Value are permitted but discouraged. The lint command will produce a warning for Value-typed event fields because they bypass schema evolution guarantees.

  5. Map keys. Value cannot be used as a Map key type.

Value Access Methods

MethodReturn TypeDescription
as_string()String?Extract as String or null
as_int()Int?Extract as Int or null
as_decimal()Decimal?Extract as Decimal or null
as_bool()Bool?Extract as Bool or null
as_list()List<Value>?Extract as list or null
as_map()Map<String, Value>?Extract as map or null
get(String)Value?Access nested field by key
let raw: Value = api_response.body
let name = raw.get("user").get("name").as_string() ?? "unknown"

Type Compatibility Summary

Source TypeTarget TypeCompatible?
IntDecimalYes (implicit widening)
DecimalIntNo (must use .to_int())
TT?Yes (implicit wrapping)
T?TNo (must narrow or coalesce)
StringValueYes (implicit boxing)
ValueStringNo (must use .as_string())
Record ARecord BNo (nominal, even if structurally identical)
Inline objectInline objectYes (structural compatibility)

See Also