C# 15 llega con .NET 11 y trae varias mejoras muy interesantes pensadas para el día a día. Repasamos las seis novedades del lenguaje con ejemplos prácticos, para qué sirve cada una en proyectos reales y cuáles merecen tu atención de verdad.

C# 15 es la versión del lenguaje que acompaña a .NET 11 y la que usa por defecto cualquier proyecto que apunte a net11.0. Trae tipos unión, jerarquías cerradas, indexadores de extensión, argumentos en expresiones de colección y break/continue con etiqueta.
Todo funciona sin tocar LangVersion. Para probarlo, basta con instalar el SDK de .NET 11 o una versión compatible de Visual Studio 2026.
Vamos una por una, viendo los problemas del día a día que resuelve cada novedad.
OJO: .NET 11 no es una versión LTS. Es una versión STS (Standard Term Support), con soporte de dos años desde su lanzamiento (hasta noviembre de 2028 aproximadamente). La calidad es la misma que la de una LTS y recibe los mismos parches. Solo cambia la duración del soporte: las LTS (como .NET 10) tienen tres años, y las versiones pares son las LTS. Mi recomendación: si tu proyecto está en producción y prefieres cambiar poco, quédate en .NET 10 y migra cuando salga la siguiente LTS. Si quieres estas novedades del lenguaje y puedes asumir una migración a .NET 12 dentro de dos años, adelante con .NET 11 sin dudarlo. En este artículo te explico con mucho más detalle cómo funciona el soporte de .NET.
1.- ¿Qué son los tipos unión (union) en C# 15?
Un tipo unión representa un valor que puede ser uno de varios tipos concretos en vez de uno solo, pero no puede salirse de los que declares. Es como un tipo múltiple, pero fuertemente tipado.
Se declara con la palabra clave union:
public record class Pagado(string IdTransaccion);
public record class Pendiente(DateTime Vence);
public record class Rechazado(string Motivo);
public union EstadoPago(Pagado, Pendiente, Rechazado);
Cada tipo de la lista se convierte implícitamente a la unión, así que asignar es directo. Por dentro, el compilador transforma la declaración en un struct con un constructor por cada tipo y una propiedad Value (de tipo object?) donde guarda el valor real. Si el caso es un tipo por valor, como int, lo guarda "en caja" (boxing).
EstadoPago estado = new Rechazado("Tarjeta caducada");
Console.WriteLine(estado.Value); // Rechazado { Motivo = Tarjeta caducada }
La unión no hereda de nada ni añade campos. Solo agrupa tipos que ya existen y le dice al compilador "el valor es uno de estos, y nada más".
Cómo funciona el switch con una unión
La forma natural de usar una unión es la coincidencia de patrones (pattern matching): un switch donde cada rama comprueba de qué tipo es el valor y lo extrae. Si no la conoces, es el mismo switch con tipos que ya usas en C# moderno (Tipo variable => ...). Con uniones tiene una particularidad que suele despistar. Cuando escribes Pagado p => ... en un patrón sobre una unión, el patrón pregunta por el contenido (Value), no por la unión en sí. Es decir, Pagado p significa "¿lo que hay dentro es un Pagado? → Entonces dámelo como p":
string mensaje = estado switch
{
Pagado p => $"Cobrado ({p.IdTransaccion})",
Pendiente p => $"Pendiente hasta {p.Vence:d}",
Rechazado r => $"Fallo: {r.Motivo}",
null => "Sin estado",
};
Como el compilador conoce los tres casos posibles, no necesitas una rama _ de descarte. Si las cubres todas, el switch es exhaustivo. Si te falta una, recibes una advertencia al compilar.
Pero entonces ¿por qué hay una rama null ? Pues porque un struct siempre tiene valor por defecto. Un campo EstadoPago sin inicializar, o un elemento de un array de ese tipo, contiene default, y su Value es null. Ese caso se te puede colar, y si no lo tratas, el switch lanza una SwitchExpressionException en tiempo de ejecución.
Mi consejo: usa siempre null => y evita _ =>. Un _ acepta también cualquier caso nuevo que añadas más adelante, y el compilador deja de avisarte de que te falta tratarlo, que es justo lo que quieres evitar.
Añadir miembros a la unión
Una unión también puede tener cuerpo, como un struct, con métodos y propiedades (pero sin campos de instancia). Es útil para exponer algo común a todos los casos sin repetir el switch en cada sitio que lo necesite:
public union EstadoPago(Pagado, Pendiente, Rechazado)
{
public string Resumen => this switch
{
Pagado => "Cobrado",
Pendiente p => $"Vence el {p.Vence:d}",
Rechazado r => r.Motivo,
null => "Sin estado",
};
}
Aplicación práctica: el resultado de una operación que puede terminar de varias maneras. Un endpoint que devuelve "usuario creado", "email duplicado" o "datos inválidos". Un servicio que consulta una API externa y obtiene datos, un timeout o un error de autenticación. Un procesador de mensajes que recibe un conjunto cerrado de comandos. Hasta ahora lo resolvías con excepciones (caras y poco explícitas), con una interfaz o clase base solo para agrupar tipos, o con bibliotecas de terceros como OneOf. Ahora el propio lenguaje te obliga a tratar todos los casos.
Si añades un cuarto caso a la unión, todos los switch que no lo contemplan generan una advertencia en compilación. Te evita lo que pasaba antes con un simple switch : hacías un cambio, se te olvidaba en algún rincón del proyecto y descubrías el fallo en producción.
Si vienes de TypeScript o F#, esto te sonará: son las union types o discriminated unions que hay en estos lenguajes. C# llevaba años pidiéndolas a gritos. Detalles en la referencia de tipos unión.
¿Y si el conjunto de casos no son tipos sueltos sino una jerarquía de clases? Para eso hay otra novedad...
2.- Jerarquías cerradas (closed)
Con el modificador closed declaras una clase que solo puede heredarse dentro de su propio ensamblado. El compilador conoce así todos sus descendientes directos y puede comprobar la exhaustividad en un switch:
public closed record class Notificacion;
public record class Email(string Destino) : Notificacion;
public record class Sms(string Telefono) : Notificacion;
public record class Push(string Dispositivo) : Notificacion;
string Canal(Notificacion n) => n switch
{
Email e => $"correo a {e.Destino}",
Sms s => $"SMS al {s.Telefono}",
Push p => $"push a {p.Dispositivo}",
// Sin rama por defecto y sin advertencia
};
Aplicación práctica: modelos de dominio con un conjunto fijo de variantes (tipos de notificación, estados de un pedido, nodos de un árbol de expresiones, comandos de una cola de mensajes). Es la forma natural de hacer lo mismo que una unión pero con clases que ya tienes y con datos o comportamiento compartido en la clase base.
Detalles que te conviene conocer:
- Una clase
closed es implícitamente abstracta. No puedes combinarla con sealed, static ni abstract explícitamente.
- La restricción no es transitiva. O sea, un descendiente que no sea
closed puede heredarse desde otros ensamblados. Si quieres exhaustividad más abajo en el árbol "genealógico", marca también los intermedios como closed.
closed es una palabra clave contextual, es decir, solo tiene significado especial cuando aparece como modificador delante de class. En cualquier otro sitio sigue siendo un nombre válido, así que una variable o un campo llamados closed en tu código actual no van a romper.
Mi criterio para elegir: si los casos ya son clases tuyas, closed. Si quieres agrupar tipos que no tienen nada que ver entre sí (por ejemplo string, int y un record), union. Más en la documentación del modificador closed.
3.- ¿Cómo se pasan argumentos a una expresión de colección con with(...)?
Las expresiones de colección ([1, 2, 3]) llegaron en C# 12 (si quieres repasar cómo funcionan, junto con el operador spread .., te lo expliqué cuando salió todo lo nuevo de C# 12), pero tenían una limitación algo fasitidiosa: no podías indicar capacidad inicial ni tampoco un comparador. Si los necesitabas, tenías que volver al new List<T>(...) de siempre.
C# 15 arregla esto con un elemento with(...) al principio de la colección:
string[] extensiones = ["jpg", "png", "gif"];
// Capacidad inicial conocida: evita redimensionados
List<string> archivos = [with(capacity: extensiones.Length * 100), .. extensiones];
// Comparador sin distinguir mayúsculas y minúsculas
HashSet<string> permitidos = [with(StringComparer.OrdinalIgnoreCase), "Admin", "ADMIN", "editor"];
// Contendría solo dos elementos: "Admin" y "editor"
Aplicación práctica: el caso que más vas a usar es el comparador. Conjuntos de roles, cabeceras HTTP, nombres de columnas o extensiones de archivo donde las mayúsculas no importan. Antes escribías new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ... }. Ahora todo cabe en la sintaxis corta, y los inicializadores de colección también admiten with. La capacidad inicial es útil cuando construyes listas grandes a partir de otra colección y quieres evitar reasignaciones de memoria.
Más información en la referencia de expresiones de colección.
4.- Indexadores de extensión: acceso con corchetes a tipos que no son tuyos
Sí. C# 14 introdujo los miembros de extensión (bloques extension que permiten propiedades y operadores además de métodos). Ahora C# 15 les suma los indexadores. Así puedes dar acceso con corchetes a un tipo que del que no tienes su código fuente:
public static class ExtensionesDeConfiguracion
{
extension(IConfiguration config)
{
public string? this[string seccion, string clave]
=> config[$"{seccion}:{clave}"];
}
}
// Uso
string? cadena = configuracion["ConnectionStrings", "Principal"];
El bloque necesita un parámetro receptor con nombre (en este caso config), porque los indexadores son siempre miembros de instancia.
Aplicación práctica: hacer más cómodo el acceso a tipos de librerías o del propio framework. Acceder a un valor de IDictionary con una clave compuesta, leer el n-ésimo elemento de un IEnumerable<T> con numeros[2] (la documentación oficial usa justo ese ejemplo), o indexar por nombre de columna sobre un DataRow que hayamos envuelto con un wrapper.
Hay un tutorial oficial en español sobre miembros de extensión en C# 14 y C# 15.
5.- break y continue con etiqueta: salir de bucles anidados sin goto ni banderas
Desde C# 15 pones una etiqueta en un bucle (o en un switch) y la usas en break o continue para saltar directamente a ese nivel:
fila: for (int f = 0; f < tablero.Filas; f++)
{
for (int c = 0; c < tablero.Columnas; c++)
{
if (tablero[f, c].EstaBloqueada)
{
continue fila; // pasa a la siguiente fila
}
if (tablero[f, c].EsMeta)
{
break fila; // sale de los dos bucles
}
}
}
La etiqueta va directamente sobre el bucle o switch al que se refiere. Sin etiqueta, break y continue se comportan como siempre.
Sin esta etiqueta que he metido, continue pasaría a la siguiente columna (no a la siguiente fila) y break saldría solo del bucle de columnas, con el de filas siguiendo con la fila siguiente. Para conseguir lo mismo que el ejemplo Hasta ahora tenías dos malas opciones: una variable booleana que compruebas en cada nivel (un flag) o un goto. Por ejemplo, con el flag:
bool meta = false;
for (int f = 0; f < tablero.Filas && !meta; f++)
{
for (int c = 0; c < tablero.Columnas; c++)
{
if (tablero[f, c].EstaBloqueada)
{
break; // sale del bucle interior: equivale a "continue fila"
}
if (tablero[f, c].EsMeta)
{
meta = true; // avisa al bucle exterior
break;
}
}
}
Más líneas, una variable extra, la lógica repartida entre dos bucles y muuucho más complicado de entender. Con etiquetas, la intención queda clara en la propia instrucción.
Aplicación práctica: cualquier búsqueda en estructuras anidadas. Buscar un valor en una matriz, validar un fichero línea a línea con un bucle interior por campos, o cruzar dos listas hasta encontrar la primera coincidencia. Tan interesante es que el compilador ahora lleva una regla de análisis integrada, la IDE0410, que detecta esos patrones y te propone el cambio.
Es una de esas novedades pequeñas que se usan poco, pero que cuando las necesitas es una maravilla.
6.- Miembros estáticos en interfaces también en runtimes antiguos
Esta no es para todos los días, pero...
Hasta ahora, declarar un método estático con cuerpo en una interfaz exigía que el runtime de destino soportara implementaciones por defecto de interfaz (o sea, .NET Core 3 o posterior y .NET Standard 2.1). C# 15 elimina esa exigencia para los miembros estáticos no virtuales:
public interface IMedida
{
public static double ACentimetros(double metros) => metros * 100;
}
double cm = IMedida.ACentimetros(2.5);
Aplicación práctica: si mantienes una biblioteca que compila también para .NET Framework o .NET Standard, puedes agrupar funciones auxiliares junto a su contrato en vez de crear una clase estática aparte. Es una mejora de compatibilidad, no de diseño, así que el impacto en una aplicación que solo apunta a .NET moderno es nulo.
Las siguientes características siguen necesitando soporte del runtime: implementaciones por defecto de instancia, miembros static abstract y static virtual, y miembros protegidos. Detalles: miembros estáticos de interfaz y soporte del runtime.
Conclusiones
Ninguna de estas novedades va a cambiar tu forma de programar de la noche a la mañana, y está bien así. Los tipos unión y closed son las más importantes: modelan resultados y estados con comprobación del compilador, y eso evita errores reales. Pero solo las notarás si tu código tiene ese tipo de modelos, y las uniones además tienen detalles (el null, el boxing) que conviene entender antes de usarlas a lo loco.
Los argumentos de colección con with(...) son pequeños, pero los usarás cada semana, sobre todo con comparadores. Los indexadores de extensión y las etiquetas en break/continue son "azúcar sintáctico" muy puntual que agradecerás solo cuando las necesites. Y los estáticos en interfaces solo interesan si mantienes bibliotecas para .NET Framework.
Por cierto, antes de migrar, revisa los cambios que rompen compatibilidad del compilador.