Type definitions
type Shape = Circle { r: Int }
| Square { side: Int }
type Color = Red | Green | Blue
A type declares a tagged union. A variant that declares fields is a constructor; a variant with no fields is a value.
Construction
c = Circle(3) # positional, in field order
red = Red # a fieldless variant IS the value
Both are struct values. Fields are read with .:
print(c.r) # 3
There is only ever one Red, so the bare name is the variant itself. colour == Red and match colour { Red => .. } work as written:
colour = Red
colour == Red # true
contains([Red, Green], Red) # true
Red() returns the same value, so the constructor spelling remains valid.
Records
A type with one variant named after it is a record, written once:
type User { name: String, age: Int = 0 }
u = User("Ada")
print(u.name, u.age) # Ada 0
That is the same type as type User = User { name: String, age: Int = 0 }, and ecko fmt prints it in the short form. A field that may be absent is typed T? - email: String? = null.
A field can hold a variant
A field declared as a type accepts any of that type's variants, with or without fields, and refuses a variant of any other type:
type Tier = Basic | Vip
type Colour = Red | Green
type Shape = Circle { r: Int } | Square { s: Int }
type Customer { name: String, tier: Tier, badge: Shape }
Customer("Ada", Vip, Circle(2)) # fine
Customer("Ada", Red, Circle(2)) # error: field `tier` of `Customer` expects Tier, got Red
The same holds inside List<Tier> and Tier?, for the brace form, for assignment (c.tier = Basic), and for a variant ai[Tier] returned. A field declared Any accepts every value.
Field defaults
A field may declare a default, which fills it when either construction form leaves it out:
type User { name: String, age: Int = 0 }
User("Sean") # age is 0
User { name: "Sean" } # age is 0
User("Ada", 36) # an explicit value still wins
Defaulted fields come last. Otherwise User("Sean") would be ambiguous the moment a default sat between two required fields - the second argument could be filling the default or the field after it. A declaration that gets this wrong is refused, naming both fields, rather than failing at every construction site.
The default is evaluated each time you build a value, not once when the type is declared, so two values never share one:
fn starting_credits() = 25
type Trial { owner: String, credits: Int = starting_credits() }
starting_credits() runs once per Trial.
It is evaluated in the scope the type was declared in, which holds functions and builtins but not local variables:
fn seven() = 7
type A { n: Int = abs(-1) } # fine - a builtin
type B { n: Int = seven() } # fine - a function
A default naming a let or mut binding fails with an undefined-variable error when a value is constructed. Reach for a function when a default needs to depend on something.
A field with no default is still required - leaving it out fails its declared type, exactly as before.
Matching
Variants match positionally by field order, which is where a type earns its keep:
area = match shape {
Circle(r) => 3.14159 * r * r
Square(side) => side * side
}
ecko check verifies exhaustiveness: a variant with no arm and no _ is an error before the program runs. Adding a variant to a type and forgetting a match somewhere is caught at analysis time rather than in production.
Fieldless variants match by name:
match colour {
Red => "stop"
Green => "go"
Blue => "cold"
}
Built-in unions
Result (Ok/Err) and Option (Some/None) are predefined:
match parse(s) {
Ok(v) => v
Err(e) => default
}
They are ordinary data types for your own modelling, not the error channel. Nothing in the standard library returns them - failures throw. Reach for them when a value legitimately has two shapes, not to imitate error handling from another language.
Types and ai
A declared type is a schema, which is what makes typed model output ergonomic:
type Sentiment = Positive | Negative | Neutral
mood = ai[Sentiment] "classify this review: {text}"
match mood {
Positive => reply_warmly()
Negative => escalate()
Neutral => file()
}
The model is constrained to the variants, the result is coerced to one, and the match over it is exhaustiveness-checked. A struct type works the same way, with each field coerced to its declared type:
type Person { name: String, age: Int }
p = ai[Person] "extract the person from: {text}"
In mock mode an enum returns its first variant and a struct returns each field's mock, so the branch structure is exercised offline.
What type is not
Not a static type system. Field types are checked when a value is built, and drive ai schema generation and coercion, but a binding carries no declared type. Ecko stays dynamically typed - see Values & types - and the verification story is contracts.