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.