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:

  1. Por cantidad de parámetros. Solo entran al match las firmas con el número exacto de parámetros que recibió la llamada.
  2. 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.
  3. 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:

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:

OrigenDestinoResultado
numbertext"42", "3.14"
booleantext"true", "false"
nothingtext"nothing"

Cualquier otra forma X as Y es error de compilación. En particular:

# 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á usan show with "..." para imprimir a consola. show es una acción del módulo Console, que forma parte del prelude (ver "Módulos estándar → Prelude" en el archivo 10) y por eso está disponible sin use explí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:

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.