04 — Acciones y errores
← 03 — Declaraciones e instancias · README · Siguiente: 05 — Control de flujo →
Acciones
Unidad de comportamiento. Reemplaza a las funciones. Soporta recursión de forma natural.
Dos formas sintácticas — no intercambiables. La forma de una línea usa with y ->. La multilínea usa needs y returns. No se mezclan: si eliges multilínea, no se admite with/-> en la firma, y viceversa. La keyword te dice de un vistazo qué formato esperar.
# firma en una línea — privilegia la concisión
action factorial with n: number -> number
if n = 0 then return 1
return n * factorial with n - 1
end
# firma multilínea — privilegia la declaratividad
action login
needs email: text, password: text
returns User or fails with LoginError
...
end
Criterio práctico: usá la forma de una línea cuando la firma cabe cómoda en una línea (uno o dos parámetros, sin tipos de error). Usá multilínea cuando hay varios parámetros, cuando declarás errores con fails with, o cuando la firma se hace difícil de leer apretada.
Returns implícito. Si una acción no declara cláusula de retorno (ni -> X en la forma de una línea ni returns X en multilínea), retorna nothing implícitamente. Es el caso por defecto para acciones cuyo propósito es producir un efecto sin valor de salida — handlers, mutadores, comandos. Si la acción puede fallar pero no devuelve valor útil, la forma explícita es returns nothing or fails with ....
action notify_change # equivalente a -> nothing
for each listener in listeners
invalidate with listener
end
end
action deposit
needs account: Account, amount: number # sin returns — retorna nothing
account.balance = account.balance + amount
end
Las acciones siempre se invocan con `nombre with arg`, nunca como métodos. Niell no tiene métodos asociados a types: la sintaxis instancia.accion no existe como invocación, y el . se usa exclusivamente para acceso a miembros (account.balance, Math.pi, Authentication.login). Para operar sobre una instancia, se pasa como argumento (deposit with account, 100), no como receptor (account.deposit with 100 es error).
La decisión elimina la dualidad método/función, mantiene una sola forma de llamada, y hace que el lookup de qué acción se invoca dependa solo del nombre y del módulo en scope — nunca del tipo del primer argumento.
Sobrecarga — la misma acción con distintas firmas. El compilador resuelve en tres pasos:
- Por cantidad de parámetros. Solo entran al match las firmas con el número exacto de parámetros que recibió la llamada.
- Por tipos. Entre las que quedan, se descartan las que no aceptan los tipos pasados — sea por coincidencia concreta o por instanciación de un parámetro genérico.
- Por especificidad (solo entra cuando hay genéricos en juego). Un parámetro con tipo concreto es más específico que uno con tipo genérico. Una sobrecarga es más específica que otra si tiene al menos un parámetro más específico y ninguno menos específico. La más específica gana.
Si después de los tres pasos quedan dos o más sobrecargas igualmente válidas, es error de compilación.
Identidad de la firma — sólo aridad y tipos. Dos firmas con la misma tripla (nombre de acción, aridad, tipos de parámetro en orden) son la misma firma a efectos de sobrecarga. Declarar dos firmas que sólo difieren en los nombres de parámetro es error de compilación por firma duplicada. Los nombres son etiquetas para el call-site; no participan de la resolución. Si dos acciones representan operaciones distintas con la misma shape (find with email vs find with username), llevan nombres distintos (find_by_email, find_by_username).
La sobrecarga por tipo de retorno no existe en Niell: una llamada debe ser comprensible leyendo solo esa línea, sin depender del tipo del receptor. Si dos acciones retornan tipos distintos, llevan nombres distintos.
action greet with name: text -> text
return "Hello {name}"
end
action greet with name: text, language: text -> text
if language = "es" then return "Hola {name}"
return "Hello {name}"
end
greet with "Daniel" # usa la primera — un parámetro
greet with "Daniel", "es" # usa la segunda — dos parámetros
La regla de especificidad habilita tener una versión genérica como default y especializar concretamente cuando la versión concreta aporta algo no expresable en la genérica — una representación más compacta, una ruta optimizada para un tipo numérico, una rama que evita cómputo redundante:
action serialize of T with value: T -> text
return Reflect.encode with value # ruta genérica: introspección estructural
end
action serialize with value: number -> text # versión concreta, especializada para number
return value as text # ruta directa: cast del prelude, sin reflexión
end
serialize with 5 # concreta (más específica)
serialize with "hi" # genérica (T=text — no hay concreta para text)
El contraste hace tangible la motivación: la genérica funciona para todo type, la concreta evita el costo de reflexión cuando el tipo se conoce estáticamente.
Si dos sobrecargas son igualmente específicas para una llamada — por ejemplo, una con T en la primera posición y la otra con T en la segunda, ambas matcheando — el compilador rechaza por ambigüedad y el programador renombra una de las dos.
Paso de argumentos por nombre — `nombre = valor`.
Los argumentos pueden pasarse por posición o por nombre. La forma por nombre usa la misma sintaxis que el bloque de instanciación: nombre = valor. Útil cuando la llamada tiene varios parámetros y la posición sola no explica qué es qué.
move_camera with my_point, 30, 90 # todos posicionales
move_camera with at = my_point, tilt = 30, facing = 90 # todos nombrados
move_camera with my_point, tilt = 30, facing = 90 # mixto: posicional + nombrados
Regla precisa: los argumentos son posicionales hasta el primer nombre = expr. A partir de ahí, todos los siguientes deben ser nombrados. No se puede volver a posicional una vez empezada la zona nombrada.
move_camera with at = my_point, 30 # error — posicional después de nombrado
Los argumentos nombrados también permiten pasar parámetros en cualquier orden (dentro de la zona nombrada) y saltar opcionales con default (cuando el parámetro lo tenga). El paso por nombre nunca cambia qué sobrecarga elige el compilador — la resolución sigue siendo por cantidad y tipo de parámetros.
Esta forma reutiliza el mismo patrón "nombre = valor" del bloque de instanciación de types — una sola idea para asignar a un campo o parámetro nombrado, no una sintaxis nueva.
Retorno múltiple — si una acción necesita retornar varios valores, se define un type explícito:
Outcome
has score: number
has won: boolean
end
action play_round -> Outcome
...
end
result = play_round
status = if result.won then "ganaste" else "perdiste"
show with "{result.score} — {status}"
Record vs tagged union. Cuando los campos del retorno siempre tienen sentido juntos (un puntaje y un flag de "ganó/perdió" del mismo round), el patrón es un record como Outcome arriba. Cuando el retorno representa "uno de varios casos, posiblemente con datos distintos por caso" (éxito con valor / error con motivo / cancelado sin razón), el patrón es un tagged union — variantes con datos asociados, ver "Enums" en el archivo 05. La diferencia es semántica: record agrupa campos que coexisten; tagged union discrimina alternativas que se excluyen.
Acciones que pueden no encontrar resultado:
action find_user with id: number -> User or nothing
...
end
find_user with id
on success as user -> show with user.name
on nothing -> show with "no encontrado"
end
(Lista completa de handlers disponibles en el call-site — on success, on nothing, on failure, variantes de enum, formas con as binding — en la tabla "Formas de on" más abajo.)
Cuando la lógica asociada a cada caso es la misma acción con distinto contexto, el patrón natural es encapsularla en una acción que recibe el enum como parámetro:
action move_player with player: Player, direction: Direction
match 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
end
Las acciones no son valores. Un nombre de acción en una expresión siempre significa "invocar": factorial with 5 la invoca con argumento, game_over (cero argumentos) también la invoca. No existe forma de tomar una referencia a una acción sin ejecutarla — ni para pasarla, guardarla en variables, ni almacenarla en colecciones.
Excepción acotada — hooks fijos del compilador. Los hooks enumerados en el archivo 08 (spawn, send, ask del módulo Parallel) aceptan el nombre de una acción en posición de argumento (spawn with counter_actor, initial = 0). Ahí el nombre se resuelve estáticamente como entry point: el compilador lo trata como referencia inmediata a la acción, no como valor pasable. La regla general se mantiene; la excepción está acotada a esa lista cerrada del compilador, no es algo que una librería pueda extender. Nota de lectura: sintácticamente counter_actor en esa posición se ve idéntico a una llamada de cero argumentos. Lo desambigua la firma del hook (spawn espera nombre de acción, no valor) — el lector que llega frío debe inspeccionar la firma del hook para resolver la lectura local. Es el único lugar de Niell donde la ambigüedad sintáctica se resuelve por contexto del callee, y queda acotada a esos tres nombres.
Esta decisión simplifica el modelo: no hay closures, no hay funciones como valores, no hay dispatch dinámico por puntero a función. Las acciones sí acceden a nombres de su scope léxico — constantes e instancias del módulo, sus propios parámetros, sus propias variables locales — pero eso es resolución estática del compilador, no captura de entorno en un valor pasable. Los patrones que en otros lenguajes requieren callbacks se cubren con mecanismos más expresivos:
- Estrategias → enums +
match(comomove_playerarriba: el comportamiento se elige por el valor del enum, no pasando una función). - Observadores / handlers →
on X changesywhen. El listener vive donde corresponde, no se "registra" pasándolo a otra acción. - Iteración con transformación →
for each ... -> ...ywhere. La expresión va inline. - Polimorfismo de comportamiento por type →
doescon los cuatro hooks built-in del compilador (add,remove,contains— ver archivo 07;observable— ver archivo 08). Cubre que un type extienda la sintaxis del núcleo en esos puntos; protocolos user-defined es decisión futura (ver archivo 11).
Acciones genéricas — parámetros de tipo con `of`.
Una acción que opera sobre cualquier tipo declara sus parámetros de tipo con of T después del nombre. El compilador infiere T en cada llamada según el tipo de los argumentos. No se introduce sintaxis nueva: se reutiliza la keyword of que ya describe la forma de las colecciones (list of number).
action first_of of T with items: list of T -> T or nothing
...
end
action index_of of T with items: list of T, value: T -> number or nothing
...
end
El sufijo _of en el nombre de la acción es una convención de stdlib para acciones genéricas, no una regla del lenguaje. Su rol es puramente legible: first_of of T with items se lee como "primer elemento de T, con items", y mantiene visualmente separado el of del parámetro de tipo (que es parte del nombre extendido first_of of) del of que invoca funciones del módulo Math (floor of x, sqrt of n). El programador puede usar cualquier nombre válido — head, take_first, etc. — la convención sólo recomienda el sufijo cuando ayuda a leer.
Múltiples parámetros de tipo se separan con coma:
action combine of K, V with keys: list of K, values: list of V -> map of K to V
...
end
La declaración explícita de los parámetros con of T es obligatoria. Si el compilador encuentra un nombre de tipo en mayúscula que no está definido y no aparece en la cláusula of, lo trata como typo y emite error — nunca como un parámetro de tipo implícito. Esto elimina la clase de bugs donde un identificador mal escrito (Useer por User) pasa silenciosamente como tipo genérico.
Types genéricos
La misma sintaxis aplica a los types: parámetros de tipo declarados con of T después del nombre, separados por coma si hay más de uno. La regla de declaración explícita es la misma — un nombre en mayúscula que no aparezca en la cláusula of se trata como typo.
Stack of T
has items: list of T
does add to items
does remove from items
end
Cache of K, V
has entries: map of K to V
has max_size: number
end
# uso — el parámetro se instancia al declarar
nums: Stack of number
items = list {}
end
sessions: Cache of text, User
entries = map {}
max_size = 1000
end
Esto cierra la simetría con las acciones: of T declara parámetros de tipo en cualquier definición. list of T, map of K to V y set of T no son magia del compilador — son los mismos types genéricos que el programador puede definir, expresados con la misma sintaxis.
Casting
as solo es válido para conversiones garantizadas y no informativas — el "tipo nuevo" no agrega ni quita información del valor, solo lo presenta en otra forma. La lista es cerrada y conocida por el compilador:
| Origen | Destino | Resultado |
|---|---|---|
number | text | "42", "3.14" |
boolean | text | "true", "false" |
nothing | text | "nothing" |
Cualquier otra forma X as Y es error de compilación. En particular:
- `text as number` / `text as boolean` — no son conversiones, son análisis sintáctico (parsing) que puede fallar. Viven en acciones del módulo estándar (
parse_number,parse_boolean) que el compilador exige manejar conon success/on failure. - `Vector3 as Point` y demás casts entre types user-defined — Niell no es estructuralmente típico; dos types con la misma forma son tipos distintos.
- Conversiones entre representaciones internas de `number` (int64 ↔ float64) — no se exponen como
as; el compilador las promueve automáticamente cuando hace falta (ver "Formas de literal numérico" en el archivo 02).
# garantizadas — as siempre funciona
label = 42 as text # → "42"
flag = true as text # → "true"
mensaje = nothing as text # → "nothing"
# puede fallar — acción explícita, el compilador exige manejar el error
# (asume `use Numbers` en el módulo; parse_number no está en el prelude)
parse_number with "25"
on success as n -> ...
on failure -> show with "valor inválido"
end
El programador no puede ignorar el caso de fallo por accidente — el compilador lo rechaza igual que cualquier fails with.
Errores
Los errores son resultados posibles, no excepciones que interrumpen el flujo. El compilador exige que el llamador declare qué ocurre en cada caso — no es posible ignorar un error por accidente.
Nota: los ejemplos a partir de acá usanshow with "..."para imprimir a consola.showes una acción del móduloConsole, que forma parte del prelude (ver "Módulos estándar → Prelude" en el archivo 10) y por eso está disponible sinuseexplícito.
Caso simple — el error es texto:
action login
needs email: text, password: text
returns User or fails with text
end
login with email, password
on success -> redirect with "home"
on failure -> show with "Algo salió mal"
end
Caso estructurado — el error es un enum, el compilador verifica exhaustividad:
LoginError can be invalid_credentials, account_locked, expired_session
action login
needs email: text, password: text
returns User or fails with LoginError
end
login with email, password
on success -> redirect with "home"
on invalid_credentials -> show with "Email o contraseña incorrectos"
on account_locked -> show with "Cuenta bloqueada"
on expired_session -> redirect with "login"
end
El compilador resuelve cada on por lookup dirigido: sabe qué enum puede retornar la acción a partir de su firma. Cuando la firma menciona un solo enum (fails with LoginError), no requiere calificación explícita — on invalid_credentials basta. Si el valor no existe en el tipo declarado, es error de compilación.
Múltiples tipos de error — una acción puede declarar más de uno con `or`. Cuando la firma combina dos o más enums, el compilador exige calificar todos los handlers de caso con el nombre del enum (on EnumName.variant). Esto evita un acoplamiento frágil entre módulos: agregar una variante nueva a un enum nunca rompe call-sites del otro.
AuthError can be invalid_credentials, account_locked
DbError can be not_found, constraint_violation
action register
needs email: text, password: text
returns User or fails with AuthError or DbError
end
register with email, password
on success -> redirect with "home"
on AuthError.invalid_credentials -> show with "Credenciales inválidas"
on AuthError.account_locked -> show with "Cuenta bloqueada"
on DbError.not_found -> show with "Base de datos no disponible"
on DbError.constraint_violation -> show with "El usuario ya existe"
end
Si más adelante DbError agrega una variante que coincide en nombre con alguna de AuthError (por ejemplo, account_locked o timeout), los call-sites siguen compilando sin cambios — la calificación los aísla. La regla evita el problema clásico de "un cambio aparentemente aditivo en un módulo rompe consumidores en módulos no relacionados".
Cuando la firma tiene un solo enum, la forma corta on variant sigue siendo válida (es la del ejemplo de login arriba): no hay otro enum con el que colisionar, así que un nombre de variante identifica unívocamente el caso.
Formas de on disponibles en el call-site:
on success— la acción retornó un valor no-nothingsin erroron success as x— igual, con binding al valor retornadoon nothing— la acción retornónothing(sólo cuando la firma lo declara conor nothing)on failure— la acción falló con texto (cuando la firma usafails with text)on failure as err— igual, con binding al texto del erroron <enum_variant>— la acción falló con una variante sin datos de un enum declarado en la firmaon <enum_variant> as binding— la acción falló con una variante con datos; el binding sigue la misma regla que enmatch(1 campo → valor directo; N campos → struct con.field)
Cuando la firma incluye or nothing, on success y on nothing son ramas disjuntas y exhaustivas sobre el caso "no falló": on success cubre el valor presente, on nothing cubre la ausencia. Cuando la firma no menciona nothing, on success cubre el único caso de éxito posible y on nothing no es admisible.
Handlers con binding (`as`) — son el patrón más útil: capturan el valor retornado o el error y lo exponen como variable local dentro del handler. Permiten encadenar la lógica sin un paso extra de asignación:
action login
needs email: text, password: text
returns User or fails with text
end
login with email, password
on success as user -> show with "Bienvenido {user.name}"
on failure as err -> show with "Error: {err}"
end
La variable de binding (user, err) solo existe dentro del cuerpo del handler. Fuera, el compilador no la reconoce.
Variantes con datos en el handler. Cuando una variante de enum lleva campos asociados (ver "Enums" en el archivo 05), el handler puede bindearlos con la misma forma as. El binding obtiene el valor directo si la variante tiene 1 campo, o un struct con todos los campos si tiene N:
LoginError can be
invalid_credentials
account_locked
rate_limited has retry_after: number
end
action login
needs email: text, password: text
returns User or fails with LoginError
end
login with email, password
on success as user -> redirect with "home"
on invalid_credentials -> show with "credenciales inválidas"
on account_locked -> show with "cuenta bloqueada"
on rate_limited as retry_after -> show with "esperá {retry_after} segundos"
end
fails with en el cuerpo de una acción
Interrumpe la acción y entrega el control al bloque on failure (o on <enum_value>) del llamador. La acción no necesita retornar nada después de fails with — el control ya salió.
action withdraw
needs account: Account, amount: number
returns nothing or fails with text
if amount <= 0 then fails with "monto inválido"
if amount > account.balance then fails with "saldo insuficiente"
account.balance = account.balance - amount
end
withdraw with my_account, 500
on success -> show with "retirado"
on failure as err -> show with "no se pudo retirar: {err}"
end
fails with solo es válido si la firma de la acción declara or fails with .... El compilador rechaza un fails with en una acción cuya firma no lo permite.