wandres.dev
LOCALIZACIÓN · idiomas y regiones

Formatos: fechas, números, monedas y unidades según la región

Formatear a mano es la forma más eficiente de escribir código que funciona en tu escritorio y falla en la mitad del planeta. Esta lección recorre lo que un formateador del sistema sabe y tu cadena interpolada ignora, distingue idioma de región como dos ejes independientes, desarma los errores clásicos con calendarios, separadores, agrupaciones y monedas, y defiende la separación estricta entre el valor y su representación.

⏱ 17 min

Existe una categoría entera de defectos que comparte una firma inconfundible: nadie los reproduce, aparecen solo en algunos informes, se cierran como no reproducibles y vuelven. Casi siempre nacen del mismo gesto, que es escribir a mano la representación de un dato que tiene reglas culturales. Una fecha con barras, un precio con coma decimal, un peso en kilos, un porcentaje con el símbolo pegado al número. Cada una de esas decisiones parece trivial y todas son afirmaciones sobre el mundo que resultan falsas fuera de un radio pequeño. Lo notable del asunto es que el sistema operativo ya conoce las respuestas correctas, las mantiene actualizadas y las ofrece con menos código del que exige equivocarse. Formatear a mano no es una optimización ni un atajo: es reimplementar peor, con menos datos y sin mantenimiento, algo que ya tienes instalado.

🎯 Al terminar esta lección sabrás
  • Distinguir idioma y región como ejes independientes y elegir la fuente correcta del Locale.
  • Formatear fechas, horas e intervalos delegando calendario, ciclo horario y orden de campos.
  • Formatear números, monedas, medidas y listas con los estilos declarativos de Foundation.
  • Separar el valor persistido de su representación y eliminar el formateo manual del código.

Lo que sabe un formateador

Conviene empezar por la distinción que más defectos evita y que casi nadie modela bien: el idioma y la región son ajustes separados en el sistema, y las personas los combinan. Alguien puede tener el sistema en inglés viviendo en España, o en español viviendo en Estados Unidos, o en árabe con región de Francia. El idioma decide qué texto se busca en el catálogo; la región decide el calendario, el sistema de unidades, el primer día de la semana, el ciclo horario y los separadores numéricos. Un código que deduce el formato del idioma acierta por casualidad y falla justo en las personas migrantes y multilingües, que son además las más propensas a notarlo.

El objeto que reúne todo eso es el Locale, y la elección entre sus dos fuentes tiene consecuencias observables. Locale.current captura el valor en el momento de leerlo; Locale.autoupdatingCurrent sigue reflejando el ajuste vigente si la persona lo cambia mientras la app está abierta. Guardar un formateador construido con el primero en una propiedad estática es la receta exacta para que la app siga mostrando el formato anterior hasta que se reinicie.

let region = Locale.current.region?.identifier          // ES, US, IN, SA
let calendario = Locale.current.calendar.identifier     // gregorian, islamic, buddhist
let sistema = Locale.current.measurementSystem          // metric, us, uk
let primerDia = Locale.current.firstDayOfWeek           // domingo o lunes

Merece la pena detenerse en qué representa exactamente esa información, porque explica por qué delegar es una decisión técnica y no una cortesía. Los datos que consulta el formateador provienen del repositorio CLDR del consorcio Unicode, se actualizan con el sistema operativo y recogen cambios que nadie de tu equipo va a seguir: una región que adopta el euro, un país que abandona el horario de verano, una reforma de la moneda que cambia el número de decimales, una era japonesa nueva. Un formateador escrito a mano congela el estado del mundo en el día en que se escribió, y a partir de ahí solo puede envejecer.

⚠️
Nunca uses el formateador para transformar datos

Poner en mayúsculas, comparar cadenas o pasar a minúsculas usando el Locale activo produce el defecto más citado del oficio: en turco, la letra i en mayúscula no es I sino I con punto, de modo que un identificador convertido a mayúsculas deja de coincidir consigo mismo y una comparación insensible a mayúsculas falla solo en ese idioma. Para datos, usa siempre las variantes independientes de la configuración regional; el Locale es para lo que se muestra, nunca para lo que se compara o se envía.

Fechas, horas y calendarios

La suposición más extendida y menos consciente es que el calendario es gregoriano. No lo es en todas partes: hay regiones que usan el calendario budista, el islámico, el hebreo o el japonés por eras, y en esos calendarios el año no coincide con el que tú tienes en la cabeza. Componer una fecha extrayendo día, mes y año y uniéndolos con barras produce, además de un orden equivocado, un año que no existe en el calendario de esa persona.

let creado = tarea.fecha

Text(creado.formatted(date: .abbreviated, time: .shortened))
Text(creado.formatted(.dateTime.weekday(.wide).day().month(.wide)))
Text(creado.formatted(.relative(presentation: .named)))
Text((inicio..<fin).formatted(date: .abbreviated, time: .omitted))

Cada una de esas líneas delega más decisiones de las que parece. El orden de los campos cambia con la región, y no solo entre día, mes y año: en japonés el año precede al mes. El ciclo de doce o veinticuatro horas no depende del idioma sino de la región y del ajuste manual que la persona pueda haber tocado. El primer día de la semana es domingo en unos sitios y lunes en otros, lo que afecta a cualquier vista de calendario propia. Y el separador entre fecha y hora, la presencia del punto tras el día o el uso de un espacio fino son detalles que ninguna cadena de formato escrita a mano acierta en todas partes.

Un caso que merece mención propia es el tiempo relativo, esa frase que dice hace tres minutos o el martes pasado. Escribirlo a mano exige reimplementar umbrales, plurales y vocabulario en cada idioma; delegarlo es una línea. Y para intervalos, el sistema colapsa lo redundante por su cuenta, mostrando el mes una sola vez cuando ambas fechas caen en él, cosa que en una implementación propia siempre acaba siendo un árbol de condiciones que nadie quiere tocar.

Hay además una distinción conceptual que conviene tener clara antes de elegir el tipo del modelo, porque no todo lo que llamamos fecha es un instante. Un cumpleaños, un día festivo o la fecha de vencimiento de un documento son fechas civiles: existen en un calendario, no en la línea temporal, y no cambian cuando la persona cruza un huso horario. Guardarlos como Date produce el defecto clásico de la felicitación que se adelanta un día al viajar. La representación correcta es un conjunto de componentes de calendario, y la conversión a instante se hace solo cuando hace falta programar algo.

// Fecha civil: no es un instante y no debe convertirse a uno para guardarla
var cumple = DateComponents()
cumple.year = 1990; cumple.month = 3; cumple.day = 14
💡
Guarda instantes, no representaciones

Un instante es un punto en la línea temporal, y su representación depende de la zona horaria, del calendario y de la región de quien mira. Persiste y transmite siempre el instante, con ISO 8601 en el cable y Date en memoria, y formatea únicamente en el momento de dibujar. Una fecha guardada como texto ya formateado es un dato al que se le ha aplicado una proyección con pérdida, y no hay forma fiable de invertirla.

Números, monedas y unidades

Con los números la trampa es más silenciosa porque el resultado siempre parece plausible. El separador decimal es coma en la mayor parte de Europa y punto en el mundo anglosajón; el separador de millares es punto, coma, espacio fino o apóstrofo según el sitio; y el sistema de agrupación no siempre es de tres en tres, porque en India la agrupación es de dos en dos a partir del millar. Interpolar un número en una cadena usa la representación de depuración, que es la del inglés, y por tanto muestra a un usuario alemán un precio con el punto en el sitio equivocado, que es el defecto que más rápido erosiona la confianza en una app de dinero.

Text(cantidad.formatted(.number.precision(.fractionLength(2))))
Text(ratio.formatted(.percent))
Text(precio.formatted(.currency(code: pedido.divisa)))
Text(bytes.formatted(.byteCount(style: .file)))
Text(nombres.formatted(.list(type: .and)))

La moneda concentra la confusión conceptual más importante de todas y conviene enunciarla sin ambigüedad: el código de divisa es un dato del pedido, no una propiedad de quien mira. La región de la persona decide dónde va el símbolo, qué separadores se usan y cuántos decimales se muestran, pero jamás debe decidir en qué moneda está expresada una cantidad. Un importe de nueve con noventa y nueve euros se muestra a un estadounidense con el formato que él entiende y sigue diciendo euros. Convertir la divisa según la región es un error de dominio, no de formato, y produce cobros incorrectos.

Hay además una razón numérica para no usar coma flotante binaria con dinero. Los tipos de coma flotante no representan exactamente los decimales, de modo que las sumas acumulan error y los redondeos dejan de ser reproducibles. Decimal existe precisamente para eso, y el número de decimales de una divisa no es siempre dos: el yen no tiene decimales y el dinar de varios países tiene tres.

La cara complementaria del formateo es el análisis de lo que la persona escribe, y suele olvidarse hasta que alguien informa de que no puede introducir un precio. Un campo numérico en un teclado alemán produce una coma decimal, y convertir esa cadena con el inicializador ingenuo devuelve nulo. Los estilos de formato tienen estrategia de análisis inversa y parametrizada por la misma configuración regional, de modo que el ciclo se cierra sin escribir ninguna sustitución de caracteres.

let entrada = try? Decimal("1.234,56", format: .number.locale(.autoupdatingCurrent))
🌍

Región, no idioma

Calendario, unidades, separadores y ciclo horario vienen de la región. El idioma solo elige el texto del catálogo.

💱

La divisa es dato

El código de moneda pertenece a la transacción. La región decide cómo se escribe, nunca en qué moneda está.

📏

Unidades con uso

Una misma magnitud se expresa distinto según para qué. Declara el uso y deja que el sistema elija la unidad.

Las magnitudes físicas cierran el capítulo y son las que más se formatean a mano por desconocimiento. Measurement guarda un valor con su unidad y su estilo de formato conoce el sistema métrico o imperial de la región, pero lo interesante es el parámetro de uso: la misma distancia se expresa de forma distinta si describe una carretera, la altura de una persona o la lluvia caída, y en algunas regiones esas categorías usan sistemas mezclados. Declarar el uso y no la unidad concreta es lo que evita el resultado absurdo de mostrar la estatura de alguien en centenares de milímetros.

let altura = Measurement(value: 1.78, unit: UnitLength.meters)
Text(altura.formatted(.measurement(width: .abbreviated, usage: .personHeight)))

let temperatura = Measurement(value: 21, unit: UnitTemperature.celsius)
Text(temperatura.formatted(.measurement(width: .abbreviated, usage: .weather)))

El valor y su representación

Todo lo anterior converge en una sola regla de arquitectura que conviene enunciar de forma tajante: el formateo ocurre en el borde de salida y en ningún otro sitio. El modelo guarda un Date, un Decimal con su código de divisa, un Measurement con su unidad canónica. La capa de red envía representaciones estables e independientes de la cultura. La vista, y solo la vista, produce la cadena que verá una persona, y la produce en el momento de dibujar para que responda al idioma, a la región y a cualquier cambio de ajustes.

flowchart LR
a[Modelo: Date, Decimal, Measurement] --> b[Persistencia y red en formatos canonicos]
a --> c[Capa de presentacion]
c --> d[Locale activo: idioma y region]
d --> e[Cadena formateada para mostrar]
e --> f[Nunca vuelve al modelo]

El corolario práctico es que una cadena formateada es un final de trayecto: se muestra y se descarta. Nunca se guarda, nunca se compara, nunca se ordena y nunca se vuelve a analizar. Ordenar por una fecha formateada produce un orden alfabético sin sentido; comparar dos precios formateados falla en cuanto cambia el separador; y volver a analizar una cadena que tú mismo formateaste funciona hasta que alguien cambia de región y deja de funcionar sin aviso.

Hay dos fronteras adicionales donde la regla se olvida con frecuencia y el coste es alto. La primera es el registro de diagnóstico y la telemetría: un evento que viaja con la fecha ya formateada según la región de quien lo generó es inutilizable para agregar, porque el mismo instante llega escrito de siete maneras. La segunda es la exportación de datos que la persona se lleva, donde conviene distinguir con claridad entre un archivo destinado a otra máquina, que debe ser canónico, y un informe destinado a leerse, que debe estar formateado. Confundir ambos produce el archivo que se ve bien y que ningún sistema puede volver a leer.

// Al cable y al registro, siempre canónico
let paraElServidor = instante.formatted(.iso8601)
Formatear es una proyección con pérdida, y la arquitectura entera se sigue de ese hecho

Vale la pena mirar el formateo con las herramientas de la teoría de tipos, porque desde ahí toda la disciplina se deduce de una sola observación en lugar de aprenderse como lista de reglas. Un formateador es una función desde un valor rico hacia una cadena, parametrizada por una configuración cultural, y es una función que no tiene inversa. Un Date contiene un instante con precisión de fracción de segundo y sin ambigüedad; la cadena que produce lleva un subconjunto de esa información, filtrada por una zona horaria, recortada a la precisión que el estilo pidió y expresada en un calendario concreto. Un Decimal con código de divisa es un par ordenado con semántica; la cadena resultante mezcla ambos componentes en un texto donde el símbolo puede ser ambiguo entre países que comparten signo. En los dos casos la operación destruye información y además introduce dependencia de un parámetro externo y mutable. Una función así solo puede colocarse en un sitio dentro de un sistema bien construido: en la frontera de salida, en el último paso antes de que un ser humano lea. Todo lo que ocurre antes debe trabajar con el valor completo. Esta observación, que parece abstracta, resuelve por sí sola una lista larga de preguntas prácticas que de otro modo hay que responder una por una. Por qué no se guarda una fecha formateada en la base: porque se guardaría la proyección en lugar del valor y no se puede recuperar el original. Por qué no se compara texto: porque la comparación pertenece al dominio del valor y sobre la proyección da resultados distintos según quién la generó. Por qué no se envía por red: porque el receptor tiene otra configuración cultural y ya no puede reconstruir nada. Por qué el formateador se construye al dibujar y no se guarda en una constante: porque el parámetro cultural es mutable y una función parametrizada que memoriza su parámetro deja de ser correcta cuando el parámetro cambia. Y por qué el formateo manual es siempre un error, incluso cuando el resultado se ve bien en tu pantalla: porque estás escribiendo a mano una función cuyo dominio de parámetros tiene cientos de valores culturales y de la que solo has probado uno, el tuyo. La regla que conviene llevarse es la misma que ordena la validación en los sistemas serios: empuja la proyección lo más tarde posible y conserva el valor rico todo lo que puedas, porque cada capa que atraviesa un dato ya proyectado es una capa que ha perdido la capacidad de razonar sobre él.

📝
Lo esencial

Idioma y región son ejes independientes: el idioma elige el texto, la región elige calendario, unidades, separadores y ciclo horario. Usa Locale.autoupdatingCurrent y no memorices formateadores. Delega fechas, intervalos y tiempo relativo en los estilos de Foundation en lugar de componer con barras. El código de divisa es un dato de la transacción y el dinero se guarda en Decimal, no en coma flotante. Declara el uso de una medida en lugar de la unidad. Y formatea solo en el borde de salida: una cadena formateada se muestra y se descarta.

⚔️ Eliminar el formateo manual
  1. Busca en tu código toda interpolación directa de un número o una fecha en una cadena visible y sustitúyela por un estilo de formato.
  2. Cambia la región del dispositivo a Alemania, a India y a Arabia Saudí sin tocar el idioma, y anota qué pantallas cambian y cuáles deberían haber cambiado.
  3. Revisa dónde guardas importes: pásalos a Decimal con su código de divisa y comprueba que ninguna región convierte la moneda.
  4. Busca todo uso de mayúsculas o comparación de cadenas que dependa del Locale y sustitúyelo por la variante independiente.
  5. Localiza cualquier lugar donde una cadena formateada se guarde, se ordene o se vuelva a analizar, y reescríbelo para operar sobre el valor.