Skip to main content

Data Types

In order to use Sidex effectively, you need to understand it's type system. But don't worry, Sidex's type system is simple!

Data types constrain the form data may take and Sidex's type system knows four sorts of data types:

Opaque Types
An opaque type does not have any structure known to Sidex's type system – it is opaque. Opaque types are the primitives of the type system. Builtin types such as string or i32 are opaque types. Sidex allows you to define your own opaque types thereby extending the type system with primitives. This makes Sidex highly flexible and extensible.
Record Types
A record type defines structures, called records, with named fields of different types. Record types are like C's or Rust's struct types. Type theoretically, they are nominal product types.
Variant Types
A variant type defines a tagged union over variants of different types. Record types are like Rust's enum types or Haskell's datatype types. Type theoretically, they are nominal sum types.
Wrapper Types
A wrapper type defines a new nominal type wrapping another type. The are similar to Haskell's newtype.

While opaque types constitute the primitives of the type system. The three other sorts of types, record types, variant types, and wrapper types, are used to define new types by combining already existing types.

By convention, type names are required to be CamelCase (except for builtin types).

Four Sorts of Types

Let us now have a closer look at each sort of type.

Opaque Types

Opaque types are defined with the keyword opaque. For example:

opaque Uuid

This defines a new opaque type Uuid. Data of that type should have a specific form, e.g., be a Universally Unique Identifier (UUID). It is important to note, however, that Sidex's type system is not aware of this form and treats the type as a black box.

The representation of data of an opaque type is entirely defined by a format or language mapping. For instance, for a binary format, a UUID may be represented by 16 bytes while in JSON it may be represented as a string. For example:

"9a4654f0-8fb7-40f3-975f-a230b063b75b"

Opaque types are also used to define the builtin types of Sidex, e.g., the different integers or string. Opaque types set Sidex apart from other data modeling frameworks because opaque types enable you to define your own primitives.

Opaque types become really useful with attributes defining their representation in various formats and languages:

#[json(type = "string")]
#[rust(type = "::uuid::Uuid")]
opaque Uuid

The attributes #[json(type = "string")] and #[rust(type = "::uuid::Uuid")] tell code generators that data of type Uuid shall be represented as a string in JSON and as ::uuid::Uuid in Rust. As a result, they may generate:

schema.rs
type Uuid = ::uuid::Uuid
schema.ts
type Uuid = string

Record Types

Record types are defined with the keyword record. For example:

record Task {
id: Uuid,
description: string,
completed: bool,
}

This defines a new record type Task. Data of this type should comprise a unique task identifier id which must be of the earlier defined type Uuid, a description which must be of type string, and a flag completed which must be of type bool.

In JSON data of type Task may then be represented as follows:

{
"id": "9a4654f0-8fb7-40f3-975f-a230b063b75b",
"description": "Learn more about Sidex's type system.",
"completed": false
}

Again, how records are represented depends on the format and language mapping, however, in contrast to opaque types you do not need to explicitly tell code generators how to represent data of the type. They already know how to translate record types to the specific format or language. As a result, they may generate:

schema.rs
struct Task {
id: Uuid,
description: String,
completed: bool,
}
schema.ts
type Task = {
id: Uuid
description: string
completed: boolean
}

Fields in record types can be optional which is indicated by a ? after the name of the field.

For instance, to make the description field of the Task type optional:

record Task {
id: Uuid,
description?: string,
completed: bool,
}

By convention, fields are required to be snake_case.

Variant Types

Variant types are defined with the keyword variant. For example:

variant Progress {
Pending: string
Completed
}

This defines a new variant type Progress with two variants, Pending and Completed. Data of this type should either be an instance of Pending with an associated string, e.g., to represent a message, or an instance of Completed without any associated additional data. In contrast to Rust's enum types, there can be at most one associated type for each variant.

The JSON encoding of variant types is configurable with attributes. A possible encoding may be:

{
"status": "Pending",
"message": "Work in progress!"
}

By convention, variant names are required to be CamelCase.

Wrapper Types

Wrapper types are useful to define fresh types carrying additional invariants. For instance, not every Uuid is also an id of a task. However, every id of a task is an Uuid. This can be captured with a wrapper type:

wrapper TaskId: Uuid

This defines a new type TaskId which wraps a Uuid but is still a separate type.

Note that every datum of a wrapper type is a datum of the wrapped type but the converse is not necessarily true. In case of our example, every TaskId is a Uuid but not every Uuid is a TaskId.

In case a datum of a wrapper type can be constructed simply by wrapping any datum of the wrapped type (i.e., there are no additional invariants imposed by the wrapper type), the wrapper type can be annotated with the #[transparent] attribute. As a result, code generators may generate additional code for constructing data of the wrapper type. The #[transparent] attribute also plays a crucial role when it comes to mutations – every mutation of the wrapped type can be applied to the wrapper.

Example: A wrapper type around string for e-mail addresses. While every e-mail address is a string, we want to make sure to validate any user supplied input when constructing a string of that kind and mutations of the string are not allowed because this may invalidate the invariants.

Types Aliases

Type aliases are defined with the alias keyword. In contrast to wrapper types, they do not introduce a fresh nominal type. All they do is to bind an already existing type definition to another alternative name. For example:

alias TaskId: Uuid

Derived Types

Derived types are defined with the derived keyword. Their actual definition is then instantiated with a concrete Sidex type by a preprocessor, i.e., the actual definition of the type is derived from other types. For instance, the mutation system is realized via derived types.

For instance, the schema

record User {
#[setter]
name: string,
}

#[mutation(for User))
derived UserMutation

will be roughly equivalent to:

record User {
#[setter]
name: string,
}

#[mutation(for User)]
variant UserMutation {
#[mutation(setter for name)]
SetName: string
}

Generic Types

In Sidex, all sorts of types can be generic. A generic types has type parameters which can be instantiated with other types.

Imagine you want a Future type representing an ongoing computation which eventually yields a value of some type T where T is not fixed. With generics such a type may be defined as follows:

variant Future<T> {
Pending: string,
Completed: T,
}

Here T is a type parameter. This Future may then be used as follows:

record Task {
name: string,
future: Future<u64>,
}

Here the future field is a Future which will eventually resolve to an u64 integer.

Builtin Types

Sidex comes with some builtin types which are just opaque types defined in an internal schema which is implicitly imported.

The builtin types are:

  • string: A sequence of Unicode code points.
  • bytes: A sequence of bytes.
  • i8, i16, i32, i64: A signed integer of a certain bit width.
  • u8, u16, u32, u64: An unsigned integer of a certain bit width.
  • idx: An unsigned index into a sequence.
  • f32, f64: An IEEE-754 floating point number.
  • bool: A boolean.
  • unit: Indicates the absence of specific data.
  • Sequence<T>: A sequence of values of type T.
  • Map<K, V>: A map from keys of type K to values of type V.

In every schema, there is an implicit import:

import ::std::builtins::*

Syntactic Sugar

Most data modeling frameworks treat sequences or maps as special cases. In Sidex, there is syntactic sugar for them, however, they are defined using generic opaque types. For notational convenience, the following syntactic sugar is supported:

() ⇒ ::std::builtins::unit

[T] ⇒ ::std::builtins::Sequence<T>

[K: V] ⇒ ::std::builtins::Map<K, V>

Hence, you can use [T] and [K: V] for sequence and map types respectively.

Reference

The builtin types are just predefined as opaque types in the following schema:

builtins.sidex
//! Sidex builtin types.
//!
//! This schema is used to populate the builtin types.

/// A sequence of Unicode code points.
opaque string

/// A sequence of bytes.
opaque bytes

/// An 8-bit signed integer
opaque i8

/// A 16-bit signed integer.
opaque i16

/// A 32-bit signed integer.
opaque i32

/// A 64-bit signed integer.
opaque i64

/// An 8-bit unsigned integer.
opaque u8

/// A 16-bit unsigned integer.
opaque u16

/// A 32-bit unsigned integer.
opaque u32

/// A 64-bit unsigned integer.
opaque u64

/// Type used for indexing sequences.
///
/// This is an unsigned integer of target-specific bit width.
opaque idx

/// A 32-bit floating-point number.
opaque f32

/// A 64-bit floating-point number.
opaque f64

/// A boolean.
opaque bool

/// Unit type indicating the absence of any specific data.
opaque unit

/// A sequence type.
opaque Sequence<T>

/// A map type.
opaque Map<K, V>