02 — Tipos
← 01 — Sintaxis · README · Siguiente: 03 — Declaraciones e instancias →
Tipos del núcleo
Primitivos:
number entero o decimal
text cadena de texto
boolean true / false
nothing ausencia de valor
Colecciones built-in genéricas:
list of X colección ordenada de elementos de tipo X
set of X conjunto sin orden ni duplicados de elementos de tipo X
map of K to V asociación de claves K a valores V
Los primitivos son tipos escalares; las colecciones son tipos genéricos que el compilador conoce de fábrica. Ambos grupos viven en el núcleo del lenguaje — están disponibles sin use. Todo lo demás vive en stdlib.
Tipos geométricos (Point, Vector3, Color) son types normales de stdlib y requieren `use` explícito: use Geometry para Point/Vector3, use Color para Color. El compilador no sabe nada de coordenadas; cada módulo define sus operaciones. Para crear instancias se usan las acciones constructoras que expone el módulo correspondiente:
origin = point with 0, 0
position = point with 10, 20
v = vector3 with 1, 0, 0
c = color with 255, 128, 0
No existe constructor implícito. El patrón "type Foo → acción constructora foo" es convención de la stdlib, no una regla del lenguaje. Niell no genera ningún constructor automático: cada módulo decide si expone una acción de construcción, cómo se llama, y qué parámetros toma. La forma siempre disponible para crear una instancia es el bloque nombre: Tipo ... end con las asignaciones de campos — las acciones tipo point with ... son simplemente azúcar que cada módulo elige ofrecer.
Codificación del tipo text
text se almacena internamente como UTF-8. Cuando una operación habla de "longitud" o "carácter" — sea de stdlib o del programador — la unidad es el code point Unicode, no el byte: "café" tiene cuatro caracteres, "👋" tiene uno (aunque ocupe cuatro bytes en memoria).
La concatenación con + y los string templates trabajan a nivel de bytes UTF-8 — válida porque concatenar dos secuencias UTF-8 bien formadas preserva los code points por construcción.
Las comparaciones (>, <, >=, <=) son lexicográficas por code point, sin collation ni reglas de locale. La ñ va donde Unicode la coloca, no donde el español la pondría. Si se necesita comparación con reglas locales (orden alfabético español, alemán, japonés), vive en stdlib (Locale.compare with a, b).
Sin normalización automática. Si dos strings se ven iguales pero usan formas Unicode distintas ("é" como un solo code point vs "e" + acento combinante), Niell los considera distintos. Es la misma filosofía que con la igualdad estructural: dos cosas que se ven iguales pero internamente son distintas, no son iguales. Para normalizar, el programador lo pide explícitamente (Unicode.normalize_nfc with text).
Grafemas compuestos. Un emoji con modificadores ("👨👩👧") ocupa varios code points y se cuenta como varios "caracteres". El conteo a nivel grafema (lo que un humano ve como un solo símbolo) requiere segmentación Unicode más costosa y vive en stdlib (Unicode.graphemes with text).
Relaciones estructurales
X has Y composición
X does Y comportamiento
Niell no tiene herencia clásica (subtipado, polimorfismo por jerarquía). Composición (has) y enums (can be) cubren la mayoría de los casos donde otros lenguajes usarían herencia: para "X es una variante de Y" se usa enum (X can be a, b, c); para "X agrupa varios campos" se usa has. Los protocolos (does) hoy se limitan a cuatro hooks fijos del compilador — add, remove, contains (para colecciones) y observable (para reactividad). Protocolos user-defined es decisión futura (ver "Protocolos user-defined" en el archivo 11).
Control
if A then B una línea, un statement
if A then B else C una línea con alternativa
if A then B else if C then D encadenado — evitar; preferir match
if A multilínea — cierra con end
match X ramificación múltiple
when condition transición — se dispara al pasar de falso a verdadero
on X changes reacción a cambio de valor
for each X in Y iteración
repeat N times loop
A derives from B valor computado
Valores derivados
derives from declara un campo cuyo valor se calcula a partir de otros. Solo puede aparecer dentro de la declaración de un type — no existe a nivel módulo.
El campo es de solo lectura: el compilador rechaza cualquier asignación directa, dentro o fuera del type. El recálculo es automático cada vez que cambia alguna de sus dependencias.
Cart
has quantity: number
has unit_price: number
subtotal derives from quantity * unit_price
tax derives from subtotal * 0.21
total derives from subtotal + tax
end
Tipo inferido o anotado. El tipo del campo derivado se infiere de la expresión por defecto — subtotal arriba es number porque quantity * unit_price lo es. Si se prefiere documentar el tipo en la firma del type, se anota igual que cualquier variable y el compilador lo trata como restricción que debe coincidir con el inferido:
subtotal: number derives from quantity * unit_price
Es la misma regla del resto del lenguaje: sin declaración → infiere; con declaración → restringe.
Si quantity cambia, subtotal, tax y total se actualizan automáticamente en el orden correcto. El compilador construye el grafo de dependencias en compilación — si hay un ciclo, es error de compilación:
Loop
a derives from b + 1
b derives from a * 2 # error de compilación — ciclo a → b → a
end
Qué puede aparecer en la expresión. El compilador permite exclusivamente este conjunto de construcciones — una whitelist sintáctica, sin análisis de pureza ni anotaciones de efectos:
- Literales (
42,"text",true,false,nothing). - Acceso a campos del mismo type (los declarados arriba en el bloque).
- Constantes del módulo (nombres declarados con
=al nivel módulo). - Operadores aritméticos, de comparación y lógicos, incluyendo
is nothing. if/then/elseymatchcomo expresiones.- String templates.
- Funciones del módulo
Mathdel prelude (lista completa en el archivo 10). - Lecturas sobre colecciones que no mutan (
map["key"],set contains x).
Lo que el compilador rechaza en el RHS: llamadas a acciones definidas por el programador, acceso a campos de otras instancias (incluidas las instancias a nivel módulo como world_canvas), y cualquier operación que mute (add, remove, asignaciones). Si la relación requiere algo de eso, se usa on changes — ese es el mecanismo para reacciones con lógica externa o efectos.
La whitelist es una decisión deliberada: hace decidible la regla sin un análisis de pureza global, evita que un cambio en una acción a distancia rompa derives from por transitividad, y deja derives from enfocado en lo que es — relación estructural pura entre campos de un type.
Costo en runtime. El grafo de dependencias se construye una sola vez al compilar — es información del type, no de cada instancia. Por instancia, lo único que se guarda son los valores de los campos (incluidos los derivados, que se almacenan con su valor más reciente; no se recomputan en cada lectura). Cuando un campo base muta, el compilador emite, en el mismo punto del código, la actualización de los campos derivados que dependen de él, recorriéndolos en orden topológico del grafo estático. La recomputación es eager: el campo derivado está siempre al día desde el punto de vista del lector.
En memoria, una instancia con campos derivados cuesta lo mismo que una con todos los campos has: los derivados son slots como cualquier otro, solo que su valor lo administra el compilador en lugar del programador. No hay grafo por instancia.
Si dentro de una acción varios campos base cambian, un mismo derivado puede recomputarse varias veces (una por cada dependencia que cambia), pero el handler on changes correspondiente dispara una sola vez al cierre transaccional con el valor final (ver "Modelo de ejecución" en el archivo 08).
Recomputación que aborta. Las expresiones permitidas en el RHS pueden abortar el contexto: división por cero, out-of-bounds en lista, overflow. Si la recomputación de un campo derivado aborta, la acción top-level que disparó la transacción reactiva falla, igual que si el abort ocurriera dentro de la acción directamente. El llamador externo lo recibe vía on failure. Los efectos ya aplicados durante el flush no se deshacen — Niell garantiza orden y coalescing en la transacción reactiva, no atomicidad sobre efectos.
Consecuencia: la invariante de `derives from` es eventual, no atómica. Si la recomputación aborta entre el set del campo base y la actualización del derivado, el type queda transitoriamente violando la relación declarada — el campo base ya mutó, el derivado todavía tiene el valor anterior. El handler on failure ve ese estado intermedio, y cualquier referencia que sobreviva (instancias a nivel módulo, parámetros que pasaron por valor compuesto) también. Si el programa necesita preservar la invariante incluso post-fallo, el patrón es capturar el estado original antes de la mutación y restaurarlo en on failure — la misma disciplina que para rollback general (ver "Alcance de 'transaccional'" en el archivo 08). Atomicidad fuerte para derives from está en "Decisiones a revisar más adelante" en el archivo 11.
Relaciones reactivas entre instancias distintas — siempre `on changes`:
Cuando una instancia debe reaccionar al estado de otra, se usa on changes. Para el caso simple de una sola asignación existe la forma de una línea:
on camera.tilt changes -> world_canvas.horizon_offset = camera.tilt
Para lógica más rica, la forma multilínea:
on camera.facing changes
for each sprite in sprites.items
sprite.angle = floor of (camera.facing / 360 * sprites.pre_rendered_angles)
sprite.shadow_direction = camera.facing
end
end
derives from y on changes no compiten: derives from declara una relación estructural dentro de un type ("este campo siempre es función de los otros"), mientras que on changes describe una reacción externa que puede tocar cualquier estado del programa.
Sistema de tipos
Sin declaración → el compilador infiere el tipo. Con declaración → el tipo es una restricción que el compilador hace cumplir.
name = "Daniel" # inferido como text
name: text = "Daniel" # fijo como text — el compilador rechaza cualquier otro tipo
Operaciones válidas por tipo:
number text boolean nothing list map set
+ ✓ ✓ concat ✗ ✗ ✗ ✗ ✗
- ✓ ✗ ✗ ✗ ✗ ✗ ✗
* ✓ ✗ ✗ ✗ ✗ ✗ ✗
/ ✓ ✗ ✗ ✗ ✗ ✗ ✗
% ✓ enteros ✗ ✗ ✗ ✗ ✗ ✗
= != ✓ ✓ ✓ ✓ ✓ ✓ ✓
> < >= <= ✓ ✓ lex. ✗ ✗ ✗ ✗ ✗
and / or ✗ ✗ ✓ ✗ ✗ ✗ ✗
not ✗ ✗ ✓ ✗ ✗ ✗ ✗
La tabla es cerrada — Niell no soporta operator overloading. Un type definido por el usuario no puede redefinir +, *, <, etc. para sus instancias. Para operaciones que conceptualmente son sumas, comparaciones o composiciones sobre types compuestos (Point, Vector3, Money), el módulo dueño expone acciones con nombre: add with p1, p2, distance_between with p1, p2, cheaper_than with a, b.
La razón es de legibilidad: una llamada con nombre nunca es ambigua sobre qué operación ejecuta. La justificación detallada (por qué el overloading degrada la lectura en proporción al número de types involucrados) vive en "Sin operator overloading" del archivo 11, donde la decisión queda como revisable.
Los operadores aritméticos y de comparación exigen ambos operandos del mismo tipo. number + number y text + text son válidos; text + number es error de compilación. Para mezclar tipos en texto, se usa string template ("edad: {n}"), nunca concatenación. La única promoción automática es entre las representaciones internas de number (int64 ↔ float64), que el compilador maneja sin intervención del programador.
nothing solo admite = y !=. La forma x = nothing produce el mismo booleano que x is nothing, pero `= nothing` no participa del type narrowing, mientras que sí lo hacen is nothing (en guards de if) y match (al cubrir la rama nothing, las demás ramas ven a x como su tipo concreto — ver "Type narrowing en match" en el archivo 03). Usar siempre `is nothing`: el narrowing es lo que casi cualquier chequeo de ausencia termina necesitando. = nothing no agrega expresividad y se lee distinto sin motivo.
Las colecciones no soportan + ni ningún operador aritmético. Para unir, agregar o quitar elementos se usan add, remove y las transformaciones de for each. El operador [] se usa para acceso indexado en list (índice numérico) y en map (clave). En list[i] el índice es un number; en map[k] la clave es del tipo declarado del map. Las semánticas difieren en cómo manejan el caso "no existe": ver "Acceso indexado en colecciones" en el archivo 07.
Semántica de asignación — primitivos por valor, compuestos por referencia.
Los primitivos (number, text, boolean, nothing) son valores: asignar copia el valor. Los valores compuestos — instancias de cualquier type definido por el programador, list, set, map — se comparten por referencia. Asignar (b = a), pasar como argumento o guardar en un campo (x.field = a) liga al mismo objeto, no produce una copia. Mutar a.tilt se observa también desde b.tilt y desde x.field.tilt.
camera: Camera
position = point with 0, 0
tilt = 0
end
b = camera
camera.tilt = 90
show with b.tilt # 90 — b apunta a la misma instancia que camera
La única forma de obtener una copia independiente de un valor compuesto es construirla explícitamente — con la forma de instancia (b: Camera ... end) o un literal de colección nuevo (list { ... }) — o cruzar la frontera de un actor (los mensajes se copian estructuralmente al enviarse; ver capítulo 08).
Esto es independiente de la igualdad: dos instancias distintas con el mismo contenido siguen siendo = aunque no compartan referencia. La sección siguiente lo detalla.
Igualdad — siempre estructural y recursiva.
= y != comparan valor, no identidad de referencia. Aplica uniformemente a primitivos, colecciones e instancias de cualquier type (built-in o definido por el programador). El compilador recorre la estructura campo por campo, elemento por elemento, hasta los primitivos.
list { 1, 2, 3 } = list { 1, 2, 3 } # true
point with 0, 0 = point with 0, 0 # true
map { "a" -> 1 } = map { "a" -> 1 } # true
set { 1, 2, 3 } = set { 3, 2, 1 } # true — set sin orden
list { 1, 2, 3 } = list { 3, 2, 1 } # false — list con orden
Para list, los elementos deben coincidir en orden. Para set, basta con que el conjunto sea el mismo. Para map, las llaves y sus valores asociados deben coincidir. Para types definidos por el programador, se comparan todos los campos has recursivamente.
El costo es O(n) en el tamaño total de la estructura. Es decisión consciente — Niell prioriza no sorprender al lector ("dos cosas que se ven iguales son iguales") sobre el costo de la comparación. La identidad referencial nunca se expone como =.
Short-circuit por identidad referencial. Antes de descender estructuralmente, = chequea si los dos operandos son la misma referencia. Si lo son, el resultado es true sin recorrer la estructura. Esto resuelve el caso más frecuente y problemático — comparar una instancia consigo misma (a = a, o x.left = x.right cuando ambas apuntan al mismo nodo) — incluso si la estructura contiene ciclos. La semántica observable sigue siendo estructural: dos referencias distintas con el mismo contenido son =; la identidad es solo una vía rápida.
Limitación conocida — comparación entre dos estructuras cíclicas distintas. El short-circuit cubre a = a aun con ciclos. Pero comparar dos referencias distintas que ambas formen estructuras cíclicas (por ejemplo, dos grafos diferentes donde cada uno se referencia a sí mismo) sigue produciendo un recorrido infinito. No es un caso frecuente con types puramente de datos, pero puede aparecer en grafos. Ver "Decisiones a revisar más adelante → Igualdad estructural recursiva" en el archivo 11 para las mitigaciones candidatas (detección de ciclos durante el recorrido, hashing incremental cacheado).
Formas de literal numérico. Niell acepta decimal con o sin punto fijo, notación científica, hexadecimal (0x...), binario (0b...), y separadores de legibilidad con _:
42, 3.14, 1.5e-3, 1_000_000, 0xff, 0b1010
Los separadores _ solo cumplen rol visual — el compilador los ignora. Pueden aparecer entre dígitos, nunca al inicio o final. Los literales hex y binario producen valores con shape int (ver "Atributo shape" en el archivo 12); la notación científica produce shape float incluso sin punto decimal (1e3 es float, no int).
El compilador infiere la representación interna de number según el contexto — el programador siempre escribe number:
n = 5 # int64 internamente
x = 5.0 # float64 internamente
y = 10 / 3 # float64 — la división siempre produce decimal
z = n + x # float64 — promoción automática al mezclar
w = floor of (10 / 3) # 3 — división entera vía floor
Decisión consciente — la división siempre produce decimal. A diferencia de C, Java, Go, Rust y Python 2, donde int / int produce int (división entera), Niell promueve el resultado a float64. Es la misma elección de Python 3. La razón: la división entera silenciosa es una fuente clásica de bugs (avg = total / count retornando 0 cuando ambos son enteros chicos). Para división entera explícita, floor of (a / b) deja la intención visible.
% solo es válido cuando el compilador puede garantizar estáticamente que ambos operandos son enteros — un 7.5 % 2 o n % 2 con n proveniente de una división no parsea, evitando el clásico de obtener un float donde el resto no tiene sentido. El análisis es local y conservador: cada expresión numérica tiene una shape inferida (int o float) según estas reglas:
- Literales:
42es int;42.0es float. +,-,*: int si ambos operandos son int; float en caso contrario./: siempre float (ver "la división siempre produce decimal" arriba).floor of,ceil of,abs of: int, independientemente del shape del argumento (floor of 3.7produce 3, no 3.0).- Variables: la shape del RHS al declararse; se propaga sin re-inferirse si la variable se reasigna a algo del mismo type.
- Parámetros y valores retornados por acciones del usuario: shape indeterminada — las firmas hablan de
number, no de int/float.
Si el shape de algún operando de % no es estáticamente int, el compilador rechaza. La salida explícita es pasar el valor por floor of — floor of n produce int, así (floor of n) % 2 queda válido. El sistema de tipos sigue teniendo un solo number; la shape es atributo interno del checker, no del type, y no aparece en firmas.
7 % 3 # válido — ambos literales son int
n = 7
n % 3 # válido — n hereda shape int del literal 7
7.5 % 2.5 # error — ambos son float
n = 10 / 3
n % 2 # error — / siempre produce float, así n es float
action remainder with x: number -> number
return x % 2 # error — el parámetro x tiene shape indeterminada
end
action remainder with x: number -> number
return (floor of x) % 2 # válido — floor of produce shape int
end
Errores aritméticos — abortan el contexto, no son `fails with`.
En el núcleo del lenguaje, las operaciones aritméticas inválidas no producen valores degenerados: abortan el contexto aislado donde se ejecuta la acción, igual que la recursión infinita al agotar su presupuesto de pasos. El llamador externo lo recibe como on failure.
a / 0 # aborta — división por cero (int o float)
sqrt of -1 # aborta — dominio inválido
log of 0 # aborta — dominio inválido
big * big # aborta si excede int64 — overflow detectado en runtime
INT64_MIN / -1 # aborta — overflow en división (resultado excede int64)
INT64_MIN % -1 # aborta — mismo caso para módulo
La regla cubre todo el módulo Math: cualquier función invocada fuera de su dominio natural aborta el contexto, sin retorno degenerado. Casos no obvios: sqrt y cbrt requieren argumento no negativo; log/ln requieren estrictamente positivo; asin/acos requieren argumento en [-1, 1]. En derives from, el abort propaga al modelo transaccional como cualquier otro error — la acción top-level falla vía on failure (ver "Recomputación que aborta" más arriba en este archivo).
number nunca contiene NaN, Infinity, ni valores con wraparound. La razón es filosófica: un error aritmético en un programa típico es un bug, no un fallo esperado. Los bugs abortan el contexto; los fallos esperados usan fails with. Distinguir los dos casos en la firma de cada acción haría imposible escribir aritmética cotidiana sin contaminar toda la base de código con or fails with MathError.
El overflow entero también aborta — en +, -, *, y también en / y % para el caso particular INT64_MIN / -1 (el resultado matemático excede el rango int64). El costo de los checks en runtime depende del workload — moderado en código típico, eliminable en muchos casos por análisis de rango del compilador y por las intrinsics de overflow de LLVM. Los programas que aceptan wraparound explícito (criptografía, hashing, hot loops específicos) usan un type alternativo de stdlib.
Los dominios que realmente necesitan otras semánticas importarán un módulo específico de la familia Numerics.* cuando exista — todavía no implementada, su dirección está descrita en "Sin soporte de primer nivel para numerics intensivo" del archivo 11. Las dos formas previstas:
Numerics.IEEEFloat— soportaráNaN/Infinityy un protocolo explícito de chequeo (is_finite,is_nan). Para motores de juego, físicas, simulaciones.Numerics.FastInt— aritmética entera con wraparound silencioso, sin checks. Para criptografía y hot loops.
El núcleo permanece simple; el dominio que necesita otra cosa la elegirá cuando la familia esté disponible.