05 — Control de flujo
← 04 — Acciones y errores · README · Siguiente: 06 — Módulos y paquetes →
Enums
Un type que solo puede ser uno de sus valores posibles. Cada uno de esos valores se llama variante. El compilador rechaza cualquier otro.
Forma simple — variantes sin datos. En una sola línea, separadas por coma:
Direction can be north, south, east, west
Role can be admin, user, guest
heading: Direction = north
heading = "arriba" # error — valor inválido
Forma con datos por variante. Algunas variantes pueden llevar campos asociados — el patrón clásico de "error con contexto" o "resultado con valor". Se usa el bloque multilínea cerrado con end; cada variante en su línea, y has después del nombre introduce sus campos (uno o varios separados por coma):
LoginError can be
invalid_credentials
account_locked
rate_limited has retry_after: number
end
HttpResponse can be
ok has body: text
redirect has url: text
not_found
server_error has code: number, message: text
end
ProcessOutcome can be
ok has enriched: EnrichedTransaction
malformed_row
unknown_sku
invalid_amount
end
Las variantes con y sin datos pueden convivir en el mismo enum.
Construcción. Una variante sin datos se usa por nombre directamente. Una con datos se construye con with y sus campos nombrados, igual que se invocaría a una acción:
err: LoginError = invalid_credentials # variante sin datos
err: LoginError = rate_limited with retry_after = 30 # variante con datos
return ok with enriched = my_transaction # idem dentro de una acción
Cada variante actúa como un constructor auto-generado: invalid_credentials es de cero parámetros, rate_limited toma sus campos como parámetros nombrados. El compilador la reconoce por contexto (el tipo esperado le dice de qué enum es).
Defaults y opcionales en variantes con datos. Los campos has dentro de una variante siguen las mismas reglas que los campos de un struct (ver "Creación de instancias" en el archivo 03). Pueden ser obligatorios, tener default explícito (= expr), ser opcionales (or nothing), o combinar opcionalidad con default (or nothing = expr):
LoginError can be
invalid_credentials
account_locked
rate_limited has retry_after: number = 60, reason: text or nothing
end
err: LoginError = rate_limited # retry_after = 60, reason = nothing
err: LoginError = rate_limited with retry_after = 5 # reason = nothing
err: LoginError = rate_limited with reason = "abuse" # retry_after = 60
Variantes homónimas en uniones de enums. Cuando una variable tiene tipo EnumA or EnumB y ambos enums declaran una variante con el mismo nombre, la construcción simple es ambigua y el compilador la rechaza. El programador desambigua con la calificación EnumName.variant, la misma forma que ya usan los handlers de fails-with con dos enums (ver "Múltiples tipos de error" en el archivo 04):
LoginError can be timeout, invalid_credentials
HttpError can be timeout, network_error
err: LoginError or HttpError = timeout # error — ambigüedad
err: LoginError or HttpError = LoginError.timeout # OK
err: LoginError or HttpError = HttpError.timeout # OK
La regla evita "una construcción funciona hasta que otro módulo agrega una variante con nombre colisionante, y de repente el call-site compila distinto". La calificación explícita inmuniza el sitio.
Binding en `match`. La forma de bindear los datos depende de cuántos campos tiene la variante — la misma regla que ya usan los handlers de fails-with con on success as user:
- Cero campos: sin binding (
asno se usa). - Un campo:
as nombrebinda directamente el valor del campo. - N campos:
as nombrebinda un struct con todos los campos; se accede con.field.
match outcome
ok as enriched -> apply_success with report, enriched # 1 campo: directo
malformed_row -> apply_failure with report, "malformed_row" # 0 campos: sin as
unknown_sku -> apply_failure with report, "unknown_sku"
invalid_amount -> apply_failure with report, "invalid_amount"
end
match response
ok as body -> show with body # 1 campo: directo
redirect as url -> redirect_to with url
not_found -> show with "404"
server_error as r -> show with "Error {r.code}: {r.message}" # N campos: struct
end
La exhaustividad se mantiene: el compilador rechaza un match que no cubra todas las variantes salvo que haya else.
Las variantes con datos también funcionan en fails with — los handlers usan exactamente la misma forma (ver "Errores" en el archivo 04).
if
Una línea — un solo statement. if como expresión retorna un valor:
if player.lives = 0 then game_over
if player.lives = 0 then game_over else continue_game
tier = if score > 1000 then "oro" else "plata"
Multilínea como statement (ramas son bloques) — cierra con end:
if player.lives = 0
game_over
reset_level
end
if player.lives = 0
game_over
else
continue_game
end
Multilínea como expresión (ramas son expresiones simples, una por rama) — sin end. then y else arrancan línea:
quote = if item.in_stock
then fixed with amount = item.price
else on_request with reason = "stock variable"
La regla: end sólo aparece cuando hay un bloque de statements que cerrar. Una expresión por rama, sin bloque, termina con la última rama. Coherente con la forma de una línea (if cond then expr else expr), que ya no lleva end.
Para múltiples condiciones, preferir match en lugar de if/else encadenado — ver "Sin sujeto" más abajo para el patrón canónico.
Match
Tres formas según el contexto.
Sobre enum — exhaustividad garantizada por el compilador: Si no están todos los casos cubiertos y no hay else, es error de compilación. else -> nothing para ignorar casos explícitamente.
match player.direction
north -> player.y = player.y - 1
south -> player.y = player.y + 1
east -> player.x = player.x + 1
west -> player.x = player.x - 1
end
Con sujeto — rangos y valores, primer caso que se cumple:
match score
> 1000 -> "oro"
> 500 -> "plata"
else -> "bronce"
end
tier = match score
> 1000 -> "oro"
> 500 -> "plata"
else -> "bronce"
end
Sin sujeto — condiciones independientes (equivalente a `cond` de Lisp): Evita el anidamiento de if/else cuando las condiciones involucran variables distintas.
match
score > 1000 -> award_trophy
player.lives = 0 -> game_over
time_remaining < 10 -> play_warning_sound
else -> nothing
end
Las tres formas usan la misma sintaxis -> y el mismo else. Las tres pueden usarse también como expresión que retorna un valor — incluyendo la forma sin sujeto:
tier = match
score > 1000 -> "oro"
score > 500 -> "plata"
else -> "bronce"
end
Tipo de las ramas: todas iguales. Cuando if o match se usan como expresión, todas las ramas deben producir el mismo tipo concreto. El compilador rechaza ramas que retornan tipos distintos, incluso si son compatibles "por unión" (number y text, por ejemplo). Las uniones T or U existen solo como anotación de variable o en fails with — no se construyen ad-hoc desde if o match.
Si el resultado naturalmente puede tomar formas distintas, el patrón canónico es un tagged union explícito:
PriceQuote can be
fixed has amount: number
on_request has reason: text
end
quote = if item.in_stock
then fixed with amount = item.price
else on_request with reason = "stock variable"
# quote: PriceQuote — el llamador hace match para discriminar
Esto preserva la disciplina del lenguaje (cada tipo nombra explícitamente sus formas) y le da al consumidor un punto canónico para ramificar con match exhaustivo.
Range
Secuencias numéricas para iteración. El rango incluye ambos extremos cuando el step los alcanza. No existe forma "exclusiva" tipo until — una sola sintaxis cubre todos los casos.
1 to 10 # 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
Sin by (paso implícito de 1), ambos extremos siempre se alcanzan.
Paso — keyword by:
0 to 100 by 10 # 0, 10, 20, ... 100 — el 100 se alcanza
1 to 10 by 2 # 1, 3, 5, 7, 9 — el 10 NO se alcanza, último valor 9
Con by N, el extremo final se incluye solo si (to - from) es múltiplo exacto de N en valor absoluto. Si no, la secuencia se detiene en el último valor que cabe sin pasar el extremo. La regla garantiza que el step nunca produzca un valor fuera de [from, to].
Descendente — el compilador infiere la dirección por el orden de los extremos:
10 to 1 # 10, 9, 8, ... 1
10 to 1 by 2 # 10, 8, 6, 4, 2 — el 1 NO se alcanza, último valor 2
Si el programador necesita iterar hasta length - 1 de una colección, escribe 0 to length - 1. La forma explícita hace visible qué valor se alcanza efectivamente, sin que el lector tenga que recordar si el extremo es inclusivo o exclusivo.
Validación de `by`. El step debe cumplir tres reglas, chequeadas estáticamente cuando los operandos son literales y en runtime cuando son dinámicos:
- No puede ser cero.
1 to 10 by 0es loop infinito sin progreso. Si el literal es0, el compilador rechaza la expresión. Si el step viene de una variable y resulta0en runtime, aborta el contexto. - El signo debe coincidir con la dirección inferida por los extremos.
1 to 10 by -1(ascendente con step negativo) o10 to 1 by 2(descendente con step positivo y ambos extremos en orden descendente — elbysolo aporta magnitud, no dirección, así queby 2se interpreta comoby -2automáticamente). El compilador rechaza la combinación contradictoria cuando los signos son visibles en literales; runtime abort cuando vienen de variables. Para descender con step explícito, elbytoma el valor absoluto y el orden de los extremos define la dirección — no hay forma de escribir un step "negativo" que sirva. - El shape (int/float) del step debe coincidir con el de los extremos.
1 to 10 by 2.5(extremos int, step float) es error de compilación. Para iterar con step fraccional, convertir extremos:1.0 to 10.0 by 2.5. Esta regla siempre se chequea en compile-time porque el shape forma parte del type.
1 to 10 by 0 # error en compilación — step cero
1 to 10 by -1 # error en compilación — signo contradice dirección
1 to 10 by 2.5 # error en compilación — shape inconsistente (int vs float)
1.0 to 10.0 by 2.5 # ok — todos float
10 to 1 by 2 # ok — step toma magnitud, dirección inferida
for each n in 1 to 10
...
end
for each
Iteración sobre cualquier expresión que produzca una colección o un range. Cubre los dos modos del lenguaje — como statement (efecto, sin valor) y como expresión (transformación a una colección nueva).
Forma statement — bloque con efecto, sin valor. Cierra con end:
for each sprite in sprites.items
sprite.angle = camera.facing
sprite.shadow = true
end
for each i in 0 to count of items - 1
show with items[i]
end
Forma expresión — produce una `list of T` resultado de aplicar el cuerpo a cada elemento. La flecha -> introduce la expresión por iteración:
doubled = for each n in numbers -> n * 2
names = for each user in users -> user.name
El resultado es siempre una list, en el orden de iteración. Para producir un set o map se compone con conversiones de stdlib (set of ...).
Filtro `where` — antes de la transformación. Acepta una expresión booleana. Los elementos que no la satisfacen no entran al cuerpo:
evens = for each n in numbers where n % 2 = 0 -> n
adults = for each user in users where user.age >= 18 -> user.name
Posición sintáctica fija. El orden es siempre for each <var> in <colección> [where <cond>] [in parallel] (-> <expr> | <bloque> end). where antes de in parallel; -> o end cierran.
Totalidad del cuerpo. La expresión del cuerpo debe ser total — no puede declarar fails with ni invocar acciones con fails with sin manejar el error en el sitio. Aplica a las dos formas (statement con bloque que contiene fails with, y expresión con ->). El motivo es composicional: la lista resultante tiene que existir entera o no existir; un fallo a mitad de iteración deja la colección en estado inconsistente. Si el cuerpo legítimamente puede fallar, el patrón canónico es envolverlo en un tagged union — ver "Paralelismo de datos" en el archivo 08 para el caso for each in parallel, donde la regla aparece primero.
Iteración sobre `map`. El binding recibe entries con campos .key y .value:
for each entry in scores
show with "{entry.key}: {entry.value}"
end
biggest = for each entry in scores where entry.value > 90 -> entry.key
while
Loop controlado por una condición booleana evaluada al inicio de cada iteración. Cubre el caso donde el número de vueltas no se conoce desde el call-site — no hay una colección a recorrer ni un range definido de antemano.
while count of candidates > 0 then
p = candidates[0]
primes add p
candidates remove p
end
La sintaxis exige then como separador (consistente con if cond then ...) y end para cerrar el bloque. La condición se evalúa antes de cada iteración; si es false al entrar, el cuerpo no corre ninguna vez.
Cuándo usar `while` vs `for each`. Si la iteración es naturalmente "una vez por elemento" de una colección o range, for each lee mejor y deja el conteo al lenguaje. while aparece cuando el criterio de salida es dinámico — depende de algo que cambia dentro del cuerpo, o de I/O externa, o de la propia colección que se está consumiendo:
# preferir for each cuando hay colección o range
for each user in users
notify with user
end
# while cuando la condición no es "una pasada sobre algo"
while not socket.closed then
msg = socket.next_message
handle with msg
end
No hay `do ... while` (loop con condición al final). La equivalencia se logra con un while true ... break si la primera iteración debe correr incondicionalmente — pero la versión con condición al inicio cubre el 99% de los casos sin sintaxis extra.
Totalidad del cuerpo. Como con for each, una acción fails with invocada dentro del cuerpo debe manejar su fallo en el sitio. El motivo es el mismo: si la iteración aborta a la mitad, el estado del módulo queda parcialmente actualizado. Para fallos legítimos hay que envolver con un tagged union.
break y continue
Dos statements de escape dentro de loops (while, for each sobre colección, for each sobre range):
break— sale del loop más interno. La ejecución continúa después delend.continue— salta a la siguiente iteración del loop más interno (re-evalúa la condición enwhile, avanza el iterador enfor each).
# break: salir cuando se cumple un criterio dinámico
for each n in 1 to 1000
if n * n > target then
break
end
squares add n * n
end
# continue: filtrar dentro del loop sin condicional anidado
for each user in users
if user.banned then
continue
end
notify with user
end
Ambos statements solo afectan al loop más interno — no hay forma de break o continue a un loop exterior por nombre. Si el patrón aparece (poco común), conviene refactorizar a una acción separada con return temprano.
Usar break y continue con moderación: cuando aparecen, suelen ser síntoma de que el loop tiene dos responsabilidades. La versión con filtro explícito (for each ... where ...) o con una acción que encapsule el criterio suele leer mejor. La conveniencia existe porque hay casos donde la alternativa es peor — pero el camino canónico para "iterar y descartar" es where, no continue.
String templates
Mecanismo oficial para embeber valores de cualquier tipo dentro de texto. No es una coerción — el compilador convierte el valor internamente en el punto de interpolación.
age = 25
greeting = "Hello {name}, you are {age} years old"
Sin comillas anidadas. Una expresión interpolada dentro de {...} no puede contener literales de string. Si necesitas un string dentro de la interpolación, lo extraes a una variable y la interpolas:
# error — comillas anidadas no permitidas
show with "{score} — {if won then "ganaste" else "perdiste"}"
# correcto — extraer a variable con nombre semántico
status = if won then "ganaste" else "perdiste"
show with "{score} — {status}"
Esto mantiene el lexer simple (cada " cierra el template) y empuja al programador a nombrar las expresiones complejas, en la misma línea de disciplina que el resto del lenguaje (retornos múltiples → type explícito, comportamiento parametrizable → does, etc.). El nombre extra suele ser más legible que la expresión inline.
Las llamadas a acciones, accesos a campos, operaciones aritméticas y booleanas sí están permitidos dentro de {...} — la restricción es exclusivamente literales de string.
Para escribir llaves literales sin interpolación, se duplican — {{ produce { y }} produce }:
"La variable {{age}} contiene {age}" # → La variable {age} contiene 25
"Un objeto JSON vacío: {{}}" # → Un objeto JSON vacío: {}
Para strings sin interpolación en absoluto, raw desactiva el mecanismo de interpolación para ese string:
raw "Usa {llaves} libremente sin interpolación"
raw es un modificador léxico del literal de string que le sigue inmediatamente — no un operador sobre expresiones. Su efecto se limita a cómo se lee ese literal. Por eso no aparece en la tabla de precedencia:
raw "texto" # literal string sin interpolación
raw "a" + "b" # → (raw "a") + "b" — solo el primer literal es raw
raw ("a" + "b") # error — raw no se aplica a expresiones, solo a literales
El valor resultante es un text normal, indistinguible en tipo de cualquier otro string: la diferencia es enteramente cómo se construyó.
text + text es válido (concatenación). text + number es error de compilación — el template es la forma designada para mezclar tipos en texto.
Scope
Una variable vive en el bloque donde se declara. El bloque cierra con end (la indentación sigue las reglas generales del archivo 01).
x = 10 # nivel módulo — visible en todo el módulo
action calculate
needs n: number
y = n * 2 # solo existe dentro de calculate
return y
end
# y no existe aquí — el compilador lo rechaza
Estado a nivel módulo — inmutabilidad por defecto
A nivel módulo, una variable se declara una sola vez. El compilador rechaza reasignaciones posteriores: si una acción intenta x = 20 sobre la x del módulo, es error de compilación.
PI = 3.14159 # constante a nivel módulo
action area_of_circle with r: number -> number
return PI * r * r
end
action reset_pi
PI = 0 # error de compilación — PI no es reasignable
end
Para tener estado mutable a nivel módulo, se declara una instancia de un type. La variable que apunta a la instancia no se puede reasignar, pero sus campos sí pueden mutar (sujeto a la disciplina de has / derives from / expose del type):
camera: Camera
position = point with 0, 0
tilt = 0
facing = 0
end
action move_camera with at: Point
camera.position = at # válido — muta un campo
camera = otra_camera # error — camera no es reasignable
end
Esto empuja al programador a poner el estado dentro de types — donde la disciplina del lenguaje aplica naturalmente — en lugar de tener "variables sueltas" a nivel módulo que serían estado global desordenado. Es también lo que hace coherente la promesa de core single-threaded: no hay variables sueltas mutables que dos partes del programa puedan pisarse.
Variables locales — rebindables. Parámetros — inmutables.
Dentro de una acción, una variable local declarada con = puede reasignarse libremente. Es el caso típico del acumulador en un loop:
action sum_list with items: list of number -> number
total = 0
for each n in items
total = total + n # válido — reasignación de variable local
end
return total
end
Los parámetros, en cambio, son inmutables: actúan como una "constante de entrada" durante toda la acción. Reasignarlos es error de compilación.
action discount_price with price: number, percent: number -> number
price = price - price * percent / 100 # error — los parámetros no se reasignan
return price
end
# correcto — usar una variable local
action discount_price with price: number, percent: number -> number
final = price - price * percent / 100
return final
end
No existe una keyword tipo const para marcar una local como inmutable — una "constante local" es simplemente una variable que el programador no reasigna.
Instancias locales — la variable no se reasigna, los campos sí.
La regla de las instancias a nivel módulo (variable no reasignable, campos mutables) aplica idéntico dentro de una acción. Si una variable local se declara con la forma de instancia — nombre: Tipo ... end, con bloque de campos — apunta a esa instancia para siempre dentro de su scope; lo que muta son sus campos, no a quién apunta.
La distinción es la forma sintáctica, no la presencia de :. Una declaración con tipo explícito e inicializador (nombre: Tipo = expr) es una variable local con anotación de tipo y sigue la regla de variables locales — rebindable libremente. Es lo que pasa en items: list of number = list { 1, 2, 3 } seguido de items = list {}: el binding sigue las reglas de variable, no las de instancia.
items: list of number = list { 1, 2, 3 } # variable rebindable
items = list {} # válido — rebind
sprite: Sprite # instancia ligada (forma de bloque)
position = at
angle = 0
end
sprite = otro_sprite # error — sprite no es reasignable
sprite.angle = 45 # válido — muta un campo
action add_sprite with at: Point -> Sprite
new_sprite: Sprite
position = at
angle = 0
...
end
new_sprite.angle = 45 # válido — muta un campo
new_sprite = otro_sprite # error — la instancia local no es reasignable
return new_sprite
end
La distinción es estructural: = introduce una variable local rebindable; : Tipo introduce una instancia ligada. No hace falta una keyword nueva — la forma sintáctica de la declaración decide la disciplina.