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
ori32
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'sdatatype
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:
type Uuid = ::uuid::Uuid
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:
struct Task {
id: Uuid,
description: String,
completed: bool,
}
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 typeT
.Map<K, V>
: A map from keys of typeK
to values of typeV
.
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:
//! 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>