AML Custom Types
Introduction
Besides the built-in types like String and Number, AML lets you define your own types. A custom type (also called a record type) groups a few related values under one name, and gives each value a field name and a type.
To see why that helps, imagine a function that builds a KPI card for your dashboards:
Func kpi_card(title: String, metric: FieldRef, format: String, color: String, show_trend: Boolean) {
VizBlock {
label: title
// ...
}
}
Dashboard sales {
block revenue: kpi_card('Revenue', r(orders.total_revenue), '$#,###', '#2E7D32', true)
block orders: kpi_card('Orders', r(orders.count), '#,###', '#1565C0', false)
}
It works, but each call is a row of unnamed values. It's hard to tell which string is the format and which is the color, it's easy to swap two of them by accident, and every call has to repeat all five values.
With a custom type, you describe the settings once and pass them around as one value:
Type KpiSettings {
title: String
metric: FieldRef
format (default: '#,###'): String
color (optional): String
show_trend (default: false): Boolean
}
Func kpi_card(settings: KpiSettings) {
VizBlock {
label: settings.title
// ...
}
}
Dashboard sales {
block revenue: kpi_card(KpiSettings {
title: 'Revenue'
metric: r(orders.total_revenue)
format: '$#,###'
show_trend: true
})
block orders: kpi_card(KpiSettings {
title: 'Orders'
metric: r(orders.count)
})
}
Every value now has a label, fields you skip fall back to their defaults, and AML underlines a misspelled or missing field right in the editor.
Syntax
Declare a custom type with the Type keyword, followed by its name and a list of fields:
Type <TypeName> {
<field_name>: <field_type>
<field_name> (optional): <field_type>
<field_name> (default: <value>): <field_type>
}
- By convention, type names use
PascalCase(e.g.KpiSettings), and field names usesnake_case. - A field can use any type: a built-in type, a literal like
'asc', a union of values, or another custom type. - A field is required unless you mark it
(optional)or give it a(default: ...)value. These are two of AML's field rules, which also let a field accept repeated blocks.
Once declared, the type name works in two ways. You can use it to declare a named value, or call it like a constructor to create a value inline:
// Declare a named value
KpiSettings revenue_settings {
title: 'Revenue'
metric: r(orders.total_revenue)
}
// Create a value inline, for example as a function argument
kpi_card(KpiSettings { title: 'Orders', metric: r(orders.count) })
To read a field, use a dot: revenue_settings.title returns 'Revenue'.
Example usages
Optional fields and default values
A field marked (optional) can be left out. A field with (default: ...) can also be left out, and AML fills in the default for you.
Type KpiSettings {
title: String
format (default: '#,###'): String
color (optional): String
}
KpiSettings orders_settings {
title: 'Orders'
}
orders_settings.format // '#,###' (filled in from the default)
If your code reads a field, prefer a default value over (optional). An optional field that was left out has no value, so reading it gives you nothing to work with. A default guarantees the field always has something sensible.
Limit a field to a few values
Sometimes a field should only accept a few specific values. List them with | (read it as "or"):
Type SortSetting {
field: String
direction: 'asc' | 'desc'
}
SortSetting by_revenue {
field: 'revenue'
direction: 'up' // error: Type 'String' is not assignable to type '"asc" | "desc"'
}
If you use the same set of values in several places, give it a name with Type <Name> = .... This creates a type alias, a shorter name for an existing type:
Type SortDirection = 'asc' | 'desc'
Type SortSetting {
field: String
direction (default: 'asc'): SortDirection
}
Type TableSettings {
default_sort_direction: SortDirection
// ...
}
You've already seen aliases on the AML Types page: Type ListInt = List[Int] works the same way.
Nested types
A field's type can be another custom type. This is handy when a group of fields belongs together and you want to reuse it:
Type Threshold {
value: Number
color: String
}
Type KpiSettings {
title: String
warning: Threshold
critical: Threshold
}
KpiSettings churn_settings {
title: 'Churn rate'
warning: Threshold { value: 0.05, color: '#F59E0B' }
critical: Threshold { value: 0.1, color: '#DC2626' }
}
churn_settings.critical.color // '#DC2626'
If a group of fields is only used in one place, you can write it inline instead of declaring a separate type:
Type KpiSettings {
title: String
layout { // an inline type (`layout: {` also works here)
width: Number
height (default: 200): Number
}
}
KpiSettings revenue_settings {
title: 'Revenue'
layout { // no `:` here: `layout: {` would create a Dict instead
width: 400
}
}
revenue_settings.layout.height // 200
Use custom types with functions
Custom types are most useful as function parameters, as shown in the introduction. They also work as return types and as list elements:
Type KpiSettings {
title: String
metric: FieldRef
}
// Return a custom type
Func revenue_settings(region: String) => KpiSettings {
KpiSettings {
title: 'Revenue in ${region}'
metric: r(orders.total_revenue)
}
}
// Accept a list of custom-type values
Func first_title(items: List[KpiSettings]) => String {
items(0).title
}
Advanced: field rules
(optional) and (default: ...) are field rules: options in parentheses after a field name that change how the field behaves. AML has several more, and they let a field accept repeated blocks. That's how a chart accepts any number of calculation calc { ... } blocks, or a model any number of dimension id { ... } blocks. Holistics uses these rules to define its own built-in types, and you can use them in yours.
Most custom types don't need them. Reach for them when you want your type to read like a built-in one.
| Rule | What it does | Used with |
|---|---|---|
optional | The field can be left out | Any field |
default: <value> | The field can be left out, and gets this value | Any field |
multiple | The field can appear many times. Blocks are stored as a list | |
elem_name_as: '<key>' | Each block has a name (threshold warning { }), stored under <key> | Usually multiple |
unique_elem_name | Names must be unique. Blocks are stored by name | elem_name_as, multiple |
root_namespace | Named blocks can also be read directly from the parent value | elem_name_as, unique_elem_name |
allow_no_body | Blocks can leave out { } (model orders) | elem_name_as |
elem_type_as: '<key>' | Blocks start with your own keyword (not_null user_id { }), stored under <key> | One field per type |
unique_elem_type | Each keyword can be used once. Blocks are stored by keyword | elem_type_as |
Separate rules with commas, like (elem_name_as: 'name', multiple, unique_elem_name). A rule without a value, like multiple, is short for multiple: true.
A field that accepts repeated blocks is always optional: a value can leave it out entirely.
The names and keywords these rules store (name and kind in the examples below) are part of the value, but AML code can't read them yet. For example, churn_settings.threshold.warning.name is an error. Use the name or keyword to look up a block instead. The exception is elem_name_as on a type, which you can read.
Repeated blocks: multiple
With multiple, a field can appear as many times as you need. The blocks are stored as a list, in the order you wrote them:
Type KpiSettings {
title: String
comparison (multiple) {
label: String
offset: Int
}
}
KpiSettings revenue_settings {
title: 'Revenue'
comparison { label: 'vs last month', offset: -1 }
comparison { label: 'vs last year', offset: -12 }
}
revenue_settings.comparison(1).label // 'vs last year'
Named blocks: elem_name_as and unique_elem_name
Lists are fine until you need a specific block. To find blocks by name, give each one a name right after the field name. elem_name_as tells AML which key to store the name under, and unique_elem_name stores the blocks by that name:
Type KpiSettings {
title: String
threshold (elem_name_as: 'name', multiple, unique_elem_name) {
value: Number
color: String
}
}
KpiSettings churn_settings {
title: 'Churn rate'
threshold warning { value: 0.05, color: '#F59E0B' }
threshold critical { value: 0.1, color: '#DC2626' }
}
churn_settings.threshold.critical.color // '#DC2626'
With unique_elem_name, using the same name twice is an error (Duplicated name 'warning'.). Without it, names can repeat, and the blocks are stored as a list like with multiple alone (churn_settings.threshold(0)).
Read named blocks directly: root_namespace
Add root_namespace to skip the field name when reading a named block. Both forms below work:
Type KpiSettings {
title: String
threshold (elem_name_as: 'name', multiple, unique_elem_name, root_namespace) {
value: Number
color: String
}
}
KpiSettings churn_settings {
title: 'Churn rate'
threshold critical { value: 0.1, color: '#DC2626' }
}
churn_settings.critical.color // '#DC2626'
churn_settings.threshold.critical.color // '#DC2626'
root_namespace needs unique_elem_name, since names have to be unique to be looked up.
Blocks without a body: allow_no_body
Sometimes the name is all you need, like listing models with model orders. allow_no_body lets a named block leave out its { }:
Type Report {
model (elem_name_as: 'name', multiple, unique_elem_name, allow_no_body) {
label (optional): String
}
}
Report sales_report {
model orders // no body needed
model users { label: 'Customers' }
}
sales_report.model.users.label // 'Customers'
If the blocks never need a body, leave { } out of the type as well:
Type ReportSettings {
title: String
hidden_field (elem_name_as: 'name', multiple, unique_elem_name, allow_no_body)
}
ReportSettings sales_report {
title: 'Sales'
hidden_field customer_email
hidden_field phone_number
}
Your own keywords: elem_type_as and unique_elem_type
elem_type_as lets blocks start with a keyword you choose, instead of the field name. In not_null user_id { } below, not_null is the keyword (stored under kind) and user_id is the name (stored under column):
Type ColumnChecks {
table: String
check (elem_type_as: 'kind', elem_name_as: 'column', unique_elem_name) {
severity (default: 'error'): String
}
}
ColumnChecks orders_checks {
table: 'orders'
not_null user_id {}
unique order_id { severity: 'warn' }
}
orders_checks.check.order_id.severity // 'warn'
Any line that doesn't match another field (like table) is checked against the elem_type_as field, so the keyword can be any word. For the same reason, only one field per type can use elem_type_as.
With unique_elem_type, blocks are stored by keyword instead, and each keyword can be used only once:
Type ColumnChecks {
check (elem_type_as: 'kind', unique_elem_type) {
columns: List[String]
}
}
ColumnChecks orders_checks {
not_null { columns: ['user_id', 'order_id'] }
unique { columns: ['order_id'] }
}
orders_checks.check.not_null.columns(1) // 'order_id'
Name the value itself: elem_name_as on a type
elem_name_as also works on the type itself. Then a value of that type stores the name it's declared with, and unlike the field rules above, you can read it:
Type KpiSettings (elem_name_as: 'id') {
title: String
}
KpiSettings revenue_kpi {
title: 'Revenue'
}
revenue_kpi.id // 'revenue_kpi'
Built-in chart types use this to give each chart a name. elem_name_as is the only rule allowed on a type.