Creación de Tipos Propios
Aprende a extender Pydantic definiendo tus propios tipos de datos con lógica de validación incrustada. Utilizaremos Annotated para adjuntar metadatos y crear validaciones reutilizables en tu proyecto.
Este capítulo aborda la extensión del sistema de tipado de Pydantic V2 para diseñar tipos de datos personalizados con lógica de validación incrustada. Se estudia el uso estratégico de Annotated para asociar metadatos y reutilizar restricciones de manera limpia y desacoplada de los modelos. Además, se profundiza en el desarrollo de clases a medida mediante la implementación de métodos como __get_pydantic_core_schema__, permitiendo interactuar directamente con el motor de validación en Rust (pydantic-core). Finalmente, se analiza la creación de tipos robustos adaptados para la persistencia de datos en ORMs y bases de datos como MongoDB.
14.1. Uso del módulo Annotated
El módulo Annotated, introducido en el módulo estándar typing de Python 3.9 (y disponible a través de typing_extensions para versiones anteriores), se ha convertido en una pieza fundamental de la arquitectura de Pydantic V2. Su propósito principal es permitir la asociación de metadatos específicos del contexto o de bibliotecas de terceros a un tipo de datos existente, sin alterar el comportamiento del tipado estático (static type checking).
En las secciones previas se analizó el uso de la función Field() para añadir restricciones y valores predeterminados dentro de un modelo heredado de BaseModel. Sin embargo, Annotated ofrece un enfoque radicalmente diferente y más potente: traslada la lógica de validación, documentación y metadatos directamente al tipo de datos, desvinculándola de la estructura rígida de un modelo de Pydantic.
Estructura conceptual de Annotated
La sintaxis básica de Annotated recibe al menos dos argumentos: el tipo de datos base y uno o varios objetos de metadatos.
TEXT
Cuando Pydantic analiza una anotación de tipo que utiliza Annotated, inspecciona los metadatos suministrados en el segundo término en adelante. Si encuentra objetos reconocibles (como instancias de FieldInfo u otros objetos de validación de Pydantic), los asimila para configurar el esquema de validación principal del campo.
Declaración y equivalencia con Field()
Para comprender la utilidad de Annotated, la siguiente tabla ilustra la equivalencia exacta entre la declaración tradicional dentro de un modelo y la declaración orientada a tipos mediante Annotated:
TEXT
Esta separación de responsabilidades aporta múltiples ventajas en el desarrollo de software con Pydantic:
- Reutilización de código: Permite definir un tipo de datos con validaciones complejas una sola vez y reutilizarlo en múltiples modelos, parámetros de funciones (como en FastAPI) o estructuras de datos jerárquicas.
- Compatibilidad con herramientas de tipado: Los analizadores estáticos como mypy o pyright ignoran por completo los metadatos adicionales y tratan la variable exclusivamente como el tipo base especificado.
- Limpieza visual: Reduce la redundancia visual dentro de las clases de configuración de datos (
BaseModel).
Ejemplo práctico de implementación
A continuación, se presenta un caso de uso donde se construyen tipos personalizados reutilizables para un sistema de gestión de inventario. Se restringen cadenas de texto y valores numéricos utilizando Annotated junto con Field.
Python
Comportamiento de los valores predeterminados (default)
Un aspecto técnico crítico al utilizar Annotated es el manejo de los valores por defecto. Aunque la función Field() permite pasar el argumento default o default_factory, Pydantic no procesa los valores predeterminados si se colocan dentro del constructor de Annotated cuando se usa asignación directa en el modelo.
El estándar de Python dicta que los valores por defecto de los atributos de una clase deben asignarse explícitamente mediante el operador =.
Python
Integración de múltiples metadatos
Annotated no se limita a un único objeto de metadatos. Es viable encadenar múltiples especificaciones, e incluso combinar metadatos de Pydantic con metadatos de otras librerías de análisis o frameworks web. Pydantic aplanará e inspeccionará secuencialmente cada uno de los argumentos provistos para construir el validador final del campo.
Python
El uso extensivo de Annotated constituye la base conceptual para el diseño de tipos de datos sofisticados a medida, los cuales se profundizarán en las secciones siguientes a través del uso de protocolos de esquema avanzados y métodos nativos del núcleo de Pydantic.
14.2. Clases de tipo a medida
Aunque el módulo Annotated cubre la mayoría de los escenarios de validación comunes mediante la reutilización de Field(), existen situaciones donde la lógica de validación e inicialización de un tipo de datos es tan compleja que requiere el uso de una clase dedicada.
En Pydantic V2, cualquier clase de Python puede convertirse en un tipo de datos compatible y validable si implementa un protocolo específico que le indique a Pydantic cómo debe ser procesada durante la deserialización (validación) y la serialización (exportación).
El Protocolo de Validación en Clases
Para que Pydantic reconozca una clase personalizada directamente como una anotación de tipo válida, la clase debe exponer métodos de clase dunder (double underscore) especiales que actúen como puntos de entrada para el motor de validación interno (pydantic-core).
Los dos métodos fundamentales para la integración de clases personalizadas son:
__get_pydantic_core_schema__: Define el esquema de validación y serialización del tipo utilizando las estructuras nativas de Rust. Es el método más potente y se detallará en profundidad en la siguiente sección (14.3).__get_pydantic_bound_schema__: Se utiliza en escenarios avanzados para resolver esquemas cuando el tipo depende de parámetros genéricos o configuraciones del modelo contenedor.
Sin embargo, para crear clases a medida sin lidiar directamente con los esquemas de bajo nivel de Rust, Pydantic proporciona un enfoque de alto nivel mucho más accesible mediante el uso de clases validadoras envueltas en Annotated o mediante la implementación de métodos mágicos estándar como __get_validators__ (legado modificado en V2). El método recomendado en V2 para construir tipos basados en clases limpias es desacoplar la clase de negocio de la lógica de validación mediante decoradores de métodos o adaptadores de funciones.
Anatomía de una Clase de Tipo a Medida
Cuando encapsulamos un tipo de datos en una clase, generalmente buscamos tres objetivos:
- Validación de entrada: Asegurar que los datos crudos (cadenas, diccionarios, etc.) se puedan transformar de forma segura a nuestra clase.
- Inmutabilidad o Lógica de Negocio interna: Que el objeto resultante exponga métodos y propiedades útiles y mantenga su estado íntegro.
- Serialización: Definir cómo se transforma la instancia de nuestra clase de vuelta a un tipo primitivo (como un string o un JSON) cuando el modelo contenedor ejecuta
.model_dump()o.model_dump_json().
TEXT
Ejemplo de Implementación: Un tipo a medida para Códigos IBAN
A continuación, se desarrolla una clase para gestionar y validar códigos IBAN (International Bank Account Number). Esta clase se encarga de limpiar los espacios en blanco, verificar la longitud mínima, validar el formato y transformar la entrada en un objeto de negocio estructurado.
Python
Control de la Serialización en Clases a Medida
En el ejemplo anterior, si ejecutamos cuenta.model_dump(), Pydantic exportará el campo cuenta_iban como una instancia de la clase IBAN. Si intentamos exportarlo con cuenta.model_dump_json(), el serializador fallará debido a que por defecto no sabe cómo convertir un objeto IBAN en una cadena de texto nativa para JSON.
Para resolver esto, el esquema devuelto por __get_pydantic_core_schema__ puede modificarse para incluir un esquema de serialización explícito mediante core_schema.plain_serializer_function_ser_schema.
Python
Gracias a este esquema dual, la clase a medida se comporta de manera transparente: se valida a partir de texto, opera internamente como un objeto rico en lógica y métodos, y se vuelve a serializar en texto plano de manera automática al integrarse con APIs o bases de datos.
14.3. El método get_core_schema
El método de clase __get_pydantic_core_schema__ es el punto de entrada de más bajo nivel que ofrece Pydantic V2 para interactuar con su motor interno, pydantic-core. A diferencia de los validadores basados en decoradores o las restricciones simples de Field(), este método permite definir con total precisión matemática cómo se construye el árbol de validación y serialización en Rust para un tipo de datos determinado.
Firmas y Parámetros Esenciales
Para implementar este método, la clase o el objeto que actúa como tipo personalizado debe exponer una función con la siguiente firma estructural:
Python
cls: La clase que está definiendo el tipo a medida.source_type: El tipo de origen exacto que se ha anotado en el modelo. Esto es especialmente útil cuando una clase hereda de otra o cuando se trabaja con tipos genéricos paramétricos (por ejemplo,MiTipo[int]), permitiendo inspeccionar los argumentos de tipo pasados entre corchetes.handler: Una instancia deGetCoreSchemaHandler. Este objeto actúa como un puente hacia el sistema de generación de esquemas por defecto de Pydantic. Permite invocarhandler(source_type)ohandler.generate_schema(source_type)para obtener el esquema que Pydantic generaría de forma natural para ese tipo, dándonos la oportunidad de envolverlo, modificarlo o añadirle pre/post-validaciones.core_schema.CoreSchema: El tipo de retorno obligatorio. Consiste en un diccionario estructurado (un objeto fuertemente tipado en las definiciones internas de Pydantic) que Rust interpreta directamente para compilar el validador en memoria.
El ecosistema de pydantic_core.core_schema
Para construir esquemas válidos, es indispensable importar el módulo core_schema de la biblioteca secundaria pydantic_core. Este módulo provee decenas de funciones factoría para modelar comportamientos. Los componentes clave del flujo son:
TEXT
- Esquemas de Tipos Primitivos:
str_schema(),int_schema(),float_schema(),bool_schema(). Garantizan que el dato de entrada sea del tipo físico correspondiente y aplican coerciones si el modo no es estricto. - Esquemas de Validación Personalizada:
no_info_plain_validator_function(func): Invoca una función de Python pura que recibe únicamente el valor de entrada y devuelve el valor transformado o lanza una excepción (ValueError,TypeError).with_info_plain_validator_function(func): Invoca una función que recibe el valor y un objetoValidationInfo, el cual contiene metadatos adicionales del contexto de validación o la configuración del modelo.
- Esquemas Combinatorios:
chain_schema([esquema_1, esquema_2]): Pasa el resultado del primer esquema como entrada del segundo. Ideal para sanitizar el tipo primero (por ejemplo, asegurar que es un string) antes de procesarlo con lógica de negocio.json_or_python_schema(json_schema, python_schema): Permite bifurcar la lógica de validación dependiendo de si los datos se están deserializando desde una cadena JSON pura (model_validate_json) o desde estructuras nativas de Python como diccionarios u objetos (model_validate).
Ejemplo Práctico: Validador de Rangos IP CIDR con Esquema de Bajo Livelior
A continuación, se desarrolla un tipo de datos personalizado que valida bloques de red en formato CIDR (por ejemplo, 192.168.1.0/24). El ejemplo demuestra cómo interceptar el esquema base de texto, inyectar una función de validación propia que use la biblioteca estándar ipaddress, y dotar al tipo de un esquema de serialización transparente.
Python
Ventajas de operar en el nivel de Core Schema
Dominar el retorno de core_schema desbloquea capacidades avanzadas que están fuera del alcance de la API de alto nivel de Pydantic:
- Rendimiento optimizado: Al compilar las uniones (
union_schema) e instancias directamente en las estructuras que consume la capa de Rust, se evitan saltos innecesarios entre el intérprete de Python y la memoria nativa, maximizando la velocidad en cargas de trabajo masivas. - Manipulación del Esquema JSON: Permite alterar directamente cómo se representará el tipo en el esquema OpenAPI/JSON resultante sin necesidad de recurrir a decoradores externos como
@model_json_schema. Esto consolida toda la definición del ciclo de vida del tipo dentro de su propia clase.
14.4. Tipos para bases de datos
El ecosistema de desarrollo en Python suele exigir que las estructuras de datos viajen a través de tres capas distintas: la base de datos (relacional o no relacional), los modelos de dominio o negocio (clases puras de Python) y la capa de transporte o API (modelos de Pydantic). Diseñar tipos de datos para bases de datos implica dotar a nuestros tipos personalizados de la capacidad de interactuar limpiamente con ORMs (Object-Relational Mapping) como SQLAlchemy, SQLModel o Tortoise ORM, así como con controladores de bases de datos NoSQL (como Motor para MongoDB).
Pydantic V2 facilita esta integración permitiendo que un tipo personalizado actúe como un puente bidireccional: es capaz de instanciarse a partir de tipos nativos de bases de datos (como objetos ObjectId de MongoDB o estructuras JSONB de PostgreSQL) y sabe cómo degradarse a formatos primitivos cuando la base de datos requiere persistir la información.
El rol de la validación desde el ORM
Cuando un ORM recupera un registro de la base de datos, los datos no siempre vienen en formato de texto o JSON limpio; a menudo se presentan como objetos específicos del controlador de la base de datos. Nuestro tipo de Pydantic debe ser lo suficientemente flexible para aceptar:
- Entradas desde la API (Deserialización Externa): Habitualmente cadenas de texto, números o diccionarios crudos que provienen de un cliente HTTP.
- Entradas desde el ORM (Deserialización Interna): Objetos ya instanciados por el driver de la base de datos.
Para lograr esto de forma robusta, se utiliza core_schema.json_or_python_schema combinado con core_schema.chain_schema, asegurando que el flujo de Python acepte la instancia nativa directamente sin intentar aplicarle validaciones de texto redundantes.
Ejemplo Práctico: Tipo personalizado para identificadores de MongoDB (ObjectId)
Un caso de uso clásico y crítico en el desarrollo de APIs es la gestión de los identificadores únicos de MongoDB, conocidos como ObjectId. Estos objetos no son cadenas de texto ordinarias, sino instancias de la clase bson.ObjectId.
El siguiente ejemplo demuestra cómo crear un tipo personalizado en Pydantic V2 que acepta tanto un str hexadecimal (desde la API) como un objeto ObjectId nativo (desde la base de datos), validando su estructura y permitiendo su serialización correcta de vuelta a texto plano en las respuestas JSON.
Python
Integración con la generación automática de esquemas JSON
Como se observa en el método de clase __get_pydantic_json_schema__, Pydantic V2 permite separar por completo la lógica de validación en tiempo de ejecución de la lógica de documentación. Al interactuar con bases de datos, los tipos internos pueden ser complejos o binarios, pero la documentación generada (por ejemplo, para Swagger UI en FastAPI) debe seguir mostrando tipos limpios y estandarizados. Implementar este método asegura que las herramientas de generación automática de OpenAPI entiendan con precisión el formato de intercambio que requiere la aplicación.
Resumen del capítulo
En este capítulo hemos explorado los mecanismos avanzados que ofrece Pydantic V2 para extender el sistema de tipado más allá de las fronteras de los tipos primitivos de Python.
Comenzamos analizando el módulo Annotated, el cual permite descentralizar la lógica de validación moviendo las restricciones directamente a las declaraciones de tipo, facilitando la reutilización del código y limpiando la estructura visual de los modelos. Avanzamos hacia la creación de Clases de tipo a medida, comprendiendo el protocolo técnico que permite convertir cualquier clase de negocio en un objeto directamente validable por Pydantic mediante la inyección de esquemas duales de serialización y deserialización.
Posteriormente, nos sumergimos en las profundidades del motor con el método __get_pydantic_core_schema__, donde aprendimos a construir árboles de validación utilizando las primitivas de bajo nivel de pydantic_core para maximizar el rendimiento computacional interactuando directamente con la capa de Rust. Finalmente, aplicamos estos conceptos al diseño de Tipos para bases de datos, resolviendo la problemática común de la gestión de tipos especiales como los ObjectId de MongoDB o estructuras complejas de ORMs, garantizando una comunicación fluida y transparente entre el almacenamiento de persistencia, el dominio de la aplicación y el cliente final de la API.
© 2026 Esdocu. Contenido bajo licencia MIT.
Editar esta página