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:
kindis 2-5 lowercase ASCII letters (enforced at declaration time; see E0114).suffixis 16-50 alphanumeric characters. KSUID is the default, but any format that fits the constraint is accepted (ULID, nanoid, UUID without dashes).projectis up to 30 characters.- Short form: 18-56 characters total.
- Long form: up to 87 characters total.
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:
-
No field access without assertion. You cannot directly access fields on a
Value. You must first assert it into a known type or use theValueaccess methods. -
Serialization boundary.
Valuedata is serialized as JSON. Types that cannot be represented in JSON (such asBlob,Duration,Date,Time) are converted to their string representation when stored inside aValue. -
No type narrowing. Unlike optional types,
Valuedoes not participate in type narrowing throughischecks inifconditions. Use explicit conversion methods instead. -
Event field restriction. Event fields of type
Valueare permitted but discouraged. Thelintcommand will produce a warning forValue-typed event fields because they bypass schema evolution guarantees. -
Map keys.
Valuecannot be used as aMapkey type.
Value Access Methods
| Method | Return Type | Description |
|---|---|---|
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 Type | Target Type | Compatible? |
|---|---|---|
Int | Decimal | Yes (implicit widening) |
Decimal | Int | No (must use .to_int()) |
T | T? | Yes (implicit wrapping) |
T? | T | No (must narrow or coalesce) |
String | Value | Yes (implicit boxing) |
Value | String | No (must use .as_string()) |
Record A | Record B | No (nominal, even if structurally identical) |
| Inline object | Inline object | Yes (structural compatibility) |
See Also
- Lexical Grammar -- literal syntax for each type
- Expression Grammar -- method chains and operators by type
- Declaration Grammar --
type,enum,recorddeclarations - Semantic Constraints -- type-related validation rules