1. Cuándo hace falta un type switch
Casi todo principiante pasa por la misma etapa: «ahora guardaré un valor en any y luego... ya veré cómo me las apaño». Es un poco como meter todos los cables en una bolsa y esperar que Internet adivine por sí solo dónde está la alimentación y dónde está el HDMI. Type switch es tu forma de aclararte de manera civilizada cuando el valor llega como una interfaz (any, error o tu propia interfaz) y después hay que elegir la lógica según el tipo concreto.
Las situaciones típicas en las que un type switch realmente tiene sentido: recibes un valor de tipo any porque es «mezclado»; obtienes un error y quieres saber si es un tipo de error concreto; construyes un despachador de comandos/eventos, donde distintos comandos se representan con distintas estructuras, pero se almacenan tras una interfaz. Por cierto, Go considera oficialmente el type assertion y el type switch como una de las formas de «ver» el tipo concreto de un error, porque error es una interfaz.
2. Switch normal y type switch
Por fuera se parecen, pero mentalmente son dos herramientas distintas. Un switch normal elige la rama por el valor de una expresión (por ejemplo, cmd == "add" o day == 5). Un type switch elige la rama por el tipo del valor que hay dentro de una interfaz (por ejemplo, dentro de any hay un int o un string).
Cuando escribes un switch normal, el compilador ya sabe de antemano el tipo de la expresión. Cuando escribes un type switch, el compilador solo sabe «esto es una interfaz», y tú le pides: «comprueba qué tipo concreto hay realmente dentro y dáme una variable ya con ese tipo».
Para mayor comodidad, fijemos esto en una tabla: conviene tenerla en la cabeza para no intentar «enganchar» un type switch donde hace falta uno normal:
| ¿Qué comparamos? | Herramienta | ¿Qué se elige? | Idea de ejemplo |
|---|---|---|---|
| Valor | |
rama según el valor | «si el comando es igual a "add"...» |
| Tipo dentro de una interfaz | |
rama según el tipo | «si dentro hay un ...» |
Sintaxis: switch v := x.(type) { ... }
La escritura x.(type) no se puede usar como una expresión «por sí sola». Solo existe dentro de la construcción type switch. Es decir, no puedes escribir t := x.(type) — el compilador simplemente no lo aceptará.
Forma canónica:
switch v := x.(type) {
case int:
// v has type int
case string:
// v has type string
default:
// v is still available as x (interface), and v does not exist
}
Aquí ocurren dos cosas al mismo tiempo.
En primer lugar, x debe tener un tipo de interfaz (any, error o tu interfaz).
En segundo lugar, la variable v dentro de cada rama obtiene el tipo concreto de esa rama, y puedes trabajar con ella sin assertions adicionales.
Mini-ejemplo: función describe
Imaginemos que tenemos una función de depuración que imprime la descripción de un valor:
package main
import "fmt"
func describe(x any) {
switch v := x.(type) {
case int:
fmt.Println("int:", v) // int: 7
case string:
fmt.Println("string:", v) // string: go
default:
fmt.Printf("unknown: %T\n", x) // unknown: bool
}
}
Fíjate: dentro de case int: la variable v ya es un int, no any. Es decir, puedes hacer aritmética, comparaciones y cualquier operación permitida para int.
3. Ejemplo: despachador de comandos
Sigamos con una pequeña aplicación de consola «TaskBox» (nombre ficticio), donde guardamos tareas en memoria. Hoy no vamos a construir una arquitectura compleja; haremos una sola cosa concreta: aprender a representar comandos como distintas estructuras y procesarlos con una sola función mediante type switch.
La idea es esta: el parser de comandos devuelve any porque el comando puede tener formas distintas. Por ejemplo, AddCmd, DoneCmd, ListCmd. Y luego el despachador ramifica por tipo.
Primero definamos los tipos de comando (de forma mínima):
package main
type AddCmd struct{ Title string }
type DoneCmd struct{ ID int }
type ListCmd struct{}
Ahora hagamos un «ejecutor de comandos». En un proyecto real, más a menudo harías una interfaz con un método Run(), pero ahora lo importante es mostrar precisamente el type switch como mecanismo de despacho por tipos concretos:
package main
import "fmt"
func runCommand(cmd any) error {
switch c := cmd.(type) {
case AddCmd:
fmt.Println("ADD:", c.Title) // ADD: Buy milk
case DoneCmd:
fmt.Println("DONE:", c.ID) // DONE: 3
case ListCmd:
fmt.Println("LIST") // LIST
default:
return fmt.Errorf("unknown command type %T", cmd)
}
return nil
}
Aquí hay una ventaja importante: en cada rama c tiene un tipo concreto. En la rama AddCmd, c tiene el campo Title; en la rama DoneCmd, el campo ID. Nada de cmd.(AddCmd) por separado, nada de ok, nada de cadenas de if.
Para que sea totalmente claro, así puede verse en main:
package main
import "fmt"
func main() {
_ = runCommand(AddCmd{Title: "Buy milk"}) // ADD: Buy milk
_ = runCommand(DoneCmd{ID: 3}) // DONE: 3
_ = runCommand(ListCmd{}) // LIST
fmt.Println("ok") // ok
}
Sí, esto todavía es un «juguete», pero justo esos juguetes luego se convierten en despachadores reales: eventos, mensajes, distintas variantes de respuesta de una API, etc.
4. Matices útiles
Varios tipos en un mismo case
Cuando el número de variantes de tipo crece, casi inevitablemente querrás procesar varios tipos de la misma manera.
Por ejemplo, tipos numéricos:
package main
import "fmt"
func printNumber(x any) {
switch v := x.(type) {
case int, int64:
fmt.Printf("number: %v\n", v) // number: 10
default:
fmt.Printf("not a number: %T\n", x)
}
}
La sutileza aquí es esta: si en un case se enumeran varios tipos, la variable v no obtiene un solo tipo concreto, porque el compilador tiene que elegir una opción común para esa rama. En la práctica, v tendrá el tipo de la expresión original del type switch (es decir, el tipo de interfaz, aquí any). Por eso, si dentro de la rama hacen falta operaciones específicas para un tipo numérico concreto, normalmente se usan case separados.
Rama «todo lo que implementa una interfaz»
En un type switch puedes hacer una rama no solo por un tipo concreto, sino también por una interfaz. Esto se parece a una «comprobación de comportamiento»: no nos importa el tipo concreto, solo que tenga los métodos necesarios.
Supongamos que queremos imprimir de forma elegante todo lo que implementa fmt.Stringer. Entonces podemos escribir case fmt.Stringer:
package main
import (
"fmt"
)
type Task struct{ Title string }
func (t Task) String() string { return "Task: " + t.Title }
func show(x any) {
switch v := x.(type) {
case fmt.Stringer:
fmt.Println(v.String()) // Task: Read Go book
default:
fmt.Printf("%v\n", x)
}
}
La gracia es que case fmt.Stringer: capturará cualquier tipo que implemente esa interfaz, no solo Task.
case nil y typed nil
Con nil en interfaces está relacionada una de las trampas más molestas para principiantes, porque parece «lo he hecho todo bien y Go me está troleando». En realidad, Go no te trolea: simplemente sigue al pie de la letra el modelo «tipo + valor».
Una interfaz tiene dos estados.
El primero: la interfaz está realmente vacía (nil), es decir, no contiene ni tipo ni valor.
El segundo: la interfaz tiene un tipo, pero el valor de ese tipo es nil (por ejemplo, *Task con valor nil). En el segundo caso, la interfaz ya no es nil, porque «el tipo sí existe».
Veámoslo en un ejemplo corto:
package main
import "fmt"
type Task struct{ Title string }
func main() {
var x any
fmt.Println(x == nil) // true
var t *Task = nil
x = t
fmt.Println(x == nil) // false
fmt.Printf("type=%T\n", x) // type=*main.Task
}
Ahora lo más importante sobre type switch: case nil solo se activa en el primer caso, cuando la interfaz realmente es nil. Si dentro hay un typed nil, entonces case nil no se activará, porque dentro sí hay tipo.
Así se ve en un type switch:
package main
import "fmt"
type Task struct{ Title string }
func main() {
var t *Task = nil
var x any = t
switch v := x.(type) {
case nil:
fmt.Println("nil interface")
case *Task:
fmt.Println("typed nil pointer:", v == nil) // typed nil pointer: true
}
}
Fíjate en la última línea: en la rama case *Task: la variable v tiene tipo *Task, y al mismo tiempo es realmente nil. Es decir, typed nil no es un «nil mágico e irreal»; es un puntero nil real, solo que la interfaz a su alrededor ya no está vacía.
Orden de los case: primero lo concreto, luego lo general
Cuando usas en el type switch tanto tipos concretos como interfaces, el orden de las ramas empieza a importar. La razón es simple: si colocas antes la «red de pesca» amplia, lo atrapará todo, y la rama «estrecha» nunca llegará a ejecutarse.
Por ejemplo, Task implementa fmt.Stringer. Si pones case fmt.Stringer: por encima de case Task:, entonces la rama Task puede que nunca se ejecute para un valor Task, porque ya ha caído en fmt.Stringer.
Veámoslo de la forma más breve posible:
package main
import (
"fmt"
)
type Task struct{ Title string }
func (t Task) String() string { return "Task(" + t.Title + ")" }
func main() {
var x any = Task{Title: "Learn type switch"}
switch v := x.(type) {
case fmt.Stringer:
fmt.Println("stringer:", v.String()) // stringer: Task(Learn type switch)
case Task:
fmt.Println("task:", v.Title)
}
}
Si de verdad quieres un tratamiento aparte para Task, simplemente cambias el orden: primero case Task:, luego case fmt.Stringer:. Es parecido a las comprobaciones normales de condiciones: primero lo más específico, luego lo más general.
5. Errores típicos al trabajar con type switch
Error n.º 1: intentar usar .(type) fuera de switch.
Una situación muy frecuente: el estudiante intenta escribir algo como t := x.(type) o meter x.(type) en fmt.Printf. Eso no se puede según la sintaxis de Go: .(type) solo existe como parte de un type switch. Si necesitas extraer un tipo concreto para un solo caso, usa type assertion v, ok := x.(T). Si hay muchos casos, solo type switch.
Error n.º 2: type switch sobre un valor que no es una interfaz.
Un type switch solo funciona si la expresión tiene un tipo de interfaz. Es decir, switch x.(type) es correcto si x es any, error u otra interfaz. Si x es int, string o Task, entonces simplemente no tiene sentido: el tipo concreto ya es conocido, y el compilador no permitirá tal construcción.
Error n.º 3: esperar que case nil capture typed nil.
Es una trampa lógica: «pero dentro hay nil — entonces case nil». Sin embargo, case nil no comprueba «el valor de dentro», sino el estado de la propia interfaz. Si la interfaz almacena el tipo *Task, entonces ya no es nil, aunque el valor sea nil. En estos casos es útil imprimir directamente %T y recordar el modelo «tipo + valor».
Error n.º 4: ausencia de default y silencioso ignore de tipos desconocidos.
Si haces un type switch sobre any y no defines default, un tipo inesperado simplemente se ignorará. En los ejemplos de aprendizaje esto puede «colarse», pero en código real se convierte en errores misteriosos: la función no hizo nada, y no está claro por qué. Normalmente, default es o bien un error explícito o bien un fallback claro (por ejemplo, fmt.Printf("unknown %T", x)).
Error n.º 5: orden incorrecto de las ramas cuando hay tipos concretos e interfaces a la vez.
Si pones case fmt.Stringer: por encima de case Task:, entonces Task empezará a procesarse «como stringer», y tu lógica especializada no se ejecutará. Al final miras el código y piensas: «¿por qué la rama Task no se activa?». La respuesta suele ser sencilla: es inalcanzable debido a un case más general que va antes.
GO TO FULL VERSION