diff --git a/.badges/maturity.svg b/.badges/maturity.svg
index eb1932fd59fdac405a8bafefd21995c78be45b23_LmJhZGdlcy9tYXR1cml0eS5zdmc=..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_LmJhZGdlcy9tYXR1cml0eS5zdmc= 100644
--- a/.badges/maturity.svg
+++ b/.badges/maturity.svg
@@ -1,7 +1,7 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<svg xmlns="http://www.w3.org/2000/svg" width="103" height="20">
+<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20">
   <linearGradient id="b" x2="0" y2="100%">
     <stop offset="0" stop-color="#bbb" stop-opacity=".1" />
     <stop offset="1" stop-opacity=".1" />
   </linearGradient>
   <mask id="anybadge_1">
@@ -3,9 +3,9 @@
   <linearGradient id="b" x2="0" y2="100%">
     <stop offset="0" stop-color="#bbb" stop-opacity=".1" />
     <stop offset="1" stop-opacity=".1" />
   </linearGradient>
   <mask id="anybadge_1">
-    <rect width="103" height="20" rx="3" fill="#fff" />
+    <rect width="108" height="20" rx="3" fill="#fff" />
   </mask>
   <g mask="url(#anybadge_1)">
     <path fill="#555" d="M0 0h61v20H0z" />
@@ -9,8 +9,8 @@
   </mask>
   <g mask="url(#anybadge_1)">
     <path fill="#555" d="M0 0h61v20H0z" />
-    <path fill="#e05d44" d="M61 0h42v20H61z" />
-    <path fill="url(#b)" d="M0 0h103v20H0z" />
+    <path fill="#4C1" d="M61 0h47v20H61z" />
+    <path fill="url(#b)" d="M0 0h108v20H0z" />
   </g>
   <g
     fill="#fff"
@@ -27,7 +27,7 @@
     font-family="DejaVu Sans,Verdana,Geneva,sans-serif"
     font-size="11"
   >
-    <text x="83.0" y="15" fill="#010101" fill-opacity=".3">Alpha</text>
-    <text x="82.0" y="14">Alpha</text>
+    <text x="85.5" y="15" fill="#010101" fill-opacity=".3">Stable</text>
+    <text x="84.5" y="14">Stable</text>
   </g>
 </svg>
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index eb1932fd59fdac405a8bafefd21995c78be45b23_LmdpdGxhYi1jaS55bWw=..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_LmdpdGxhYi1jaS55bWw= 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,3 +1,8 @@
 include:
   - project: xcg/ci-templates
     file: /odoo/17.0/gitlab-ci.yaml
+
+variables:
+  TEST_OPTIONS: --test-tags=odoo_addons_mail
+  # Also install mail to run mail dependant tets
+  ODOO_SCRIPTS_MODULE_LIST: mail
diff --git a/NEWS.rst b/NEWS.rst
index eb1932fd59fdac405a8bafefd21995c78be45b23_TkVXUy5yc3Q=..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_TkVXUy5yc3Q= 100644
--- a/NEWS.rst
+++ b/NEWS.rst
@@ -1,6 +1,45 @@
 Changelog
 =========
 
+17.0.2.0.0
+----------
+
+Fixes and changes after making the module typing compliant.
+
+Evolve: Allow to skip update process.
+
+Expose Context, NewinstanceType and build_context at the top level package.
+
+Remove mail dependency, to avoid forcing its installation, only needed when using some specific converters.
+
+Breaking change: validator package does not assume a odoo.addons package name, provide full package name instead.
+
+Added Writeonly converter.
+
+Add some typing information, or make it consistent.
+
+Add more docstrings.
+
+Fix using Skip in switch converter.
+
+In Model converter, also validate messages in ``message_to_odoo``.
+
+Model converter argument changed, `__type__` is now the last argument and is optional. It is expected not to be used
+anymore.
+Added possible_datatypes property and odoo_datatype on Converter. Defaults to empty set and None.
+On Model, it can be set.
+
+Replace generic exception.
+
+Fix Switch converter to call post_hook.
+
+Xref converter:
+
+- Allow prefix on Xref converter
+- Add option to include module name in messages. Incoming and outgoing message value have the same comportment.
+  For example, if __converter__ is used as the module, both generated messages and received message will contain __converter__.<name>.
+  Previously, generated messages would use the module name while received one would not.
+
 17.0.1.0.1
 ----------
 
diff --git a/README.rst b/README.rst
index eb1932fd59fdac405a8bafefd21995c78be45b23_UkVBRE1FLnJzdA==..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_UkVBRE1FLnJzdA== 100644
--- a/README.rst
+++ b/README.rst
@@ -2,6 +2,6 @@
 Converter
 =========
 
-.. |maturity| image:: .badges/maturity.svg
+.. |maturity| image:: https://img.shields.io/badge/maturity-Stable-green.png
     :target: https://odoo-community.org/page/development-status
     :alt: Stable
@@ -6,5 +6,5 @@
     :target: https://odoo-community.org/page/development-status
     :alt: Stable
-.. |license| image:: .badges/licence-AGPL--3-blue.svg
+.. |license| image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg
     :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
     :alt: License: AGPL-3
@@ -9,5 +9,5 @@
     :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
     :alt: License: AGPL-3
-.. |ruff| image:: .badges/code_style-ruff.svg
+.. |ruff| image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json
     :target: https://github.com/astral-sh/ruff
     :alt: Ruff
@@ -12,6 +12,6 @@
     :target: https://github.com/astral-sh/ruff
     :alt: Ruff
-.. |prettier| image:: .badges/code_style-prettier-ff69b4.svg
+.. |prettier| image:: https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square
     :target: https://github.com/prettier/prettier
     :alt: Prettier
 
diff --git a/__init__.py b/__init__.py
index eb1932fd59fdac405a8bafefd21995c78be45b23_X19pbml0X18ucHk=..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_X19pbml0X18ucHk= 100644
--- a/__init__.py
+++ b/__init__.py
@@ -27,6 +27,7 @@
     PHASE_UPDATE,
     Computed,
     Constant,
+    Context,
     ContextBuilder,
     Converter,
     Newinstance,
@@ -30,5 +31,6 @@
     ContextBuilder,
     Converter,
     Newinstance,
+    NewinstanceType,
     Readonly,
     Skip,
@@ -33,3 +35,5 @@
     Readonly,
     Skip,
+    SkipType,
+    Writeonly,
     message_to_odoo,
@@ -35,4 +39,5 @@
     message_to_odoo,
+    build_context,
 )
 from .exception import InternalError
 from .field import Field, TranslatedSelection
@@ -43,9 +48,7 @@
 from .relation import RelationToMany, RelationToManyMap, RelationToOne, relation
 from .switch import Switch
 from .validate import (
-    VALIDATION_SKIP,
-    VALIDATION_SOFT,
-    VALIDATION_STRICT,
+    Validation,
     NotInitialized,
     Validator,
 )
diff --git a/__manifest__.py b/__manifest__.py
index eb1932fd59fdac405a8bafefd21995c78be45b23_X19tYW5pZmVzdF9fLnB5..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_X19tYW5pZmVzdF9fLnB5 100644
--- a/__manifest__.py
+++ b/__manifest__.py
@@ -1,7 +1,7 @@
 ##############################################################################
 #
 #    Converter Odoo module
-#    Copyright © 2020-2024 XCG Consulting <https://xcg-consulting.fr>
+#    Copyright © 2020-2025 XCG Consulting <https://xcg-consulting.fr>
 #
 #    This program is free software: you can redistribute it and/or modify
 #    it under the terms of the GNU Affero General Public License as
@@ -17,8 +17,7 @@
 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
 #
 ##############################################################################
-
 {
     "name": "Converter",
     "license": "AGPL-3",
     "summary": "Convert odoo records to/from plain data structures.",
@@ -21,8 +20,8 @@
 {
     "name": "Converter",
     "license": "AGPL-3",
     "summary": "Convert odoo records to/from plain data structures.",
-    "version": "17.0.1.0.1",
+    "version": "17.0.2.0.0",
     "category": "Hidden",
     "author": "XCG Consulting",
     "website": "https://orbeet.io/",
@@ -26,8 +25,7 @@
     "category": "Hidden",
     "author": "XCG Consulting",
     "website": "https://orbeet.io/",
-    "depends": ["base", "mail"],
-    "data": [],
+    "depends": ["base"],
     "installable": True,
     "external_dependencies": {"python": ["fastjsonschema"]},
 }
diff --git a/base.py b/base.py
index eb1932fd59fdac405a8bafefd21995c78be45b23_YmFzZS5weQ==..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_YmFzZS5weQ== 100644
--- a/base.py
+++ b/base.py
@@ -1,7 +1,7 @@
 ##############################################################################
 #
 #    Converter Odoo module
-#    Copyright © 2020 XCG Consulting <https://xcg-consulting.fr>
+#    Copyright © 2020, 2025 XCG Consulting <https://xcg-consulting.fr>
 #
 #    This program is free software: you can redistribute it and/or modify
 #    it under the terms of the GNU Affero General Public License as
@@ -36,6 +36,7 @@
 
 import inspect
 import logging
+from abc import ABCMeta, abstractmethod
 from collections.abc import Callable, Mapping
 from typing import Any
 
@@ -39,6 +40,6 @@
 from collections.abc import Callable, Mapping
 from typing import Any
 
-from odoo import api, models
+from odoo import api, models  # type: ignore[import-untyped]
 
 from .exception import InternalError
@@ -43,8 +44,8 @@
 
 from .exception import InternalError
-from .validate import Validator
+from .validate import Validation, Validator
 
 logger = logging.getLogger(__name__)
 
 
 class SkipType:
@@ -46,9 +47,20 @@
 
 logger = logging.getLogger(__name__)
 
 
 class SkipType:
-    pass
+    def get__type__(self) -> set[str]:
+        # Avoid conditions isinstance(converter, SkipType)
+        return set()
+
+    @property
+    def possible_datatypes(self) -> set[str]:
+        # Avoid conditions isinstance(converter, SkipType)
+        return set()
+
+    def odoo_datatype(self, instance: models.BaseModel) -> str | None:
+        # Avoid conditions isinstance(converter, SkipType)
+        return None
 
 
 Skip = SkipType()
@@ -60,7 +72,8 @@
 
 Newinstance = NewinstanceType()
 
-ContextBuilder = Callable[[models.Model, Mapping | None], Mapping | None]
+Context = Mapping | None
+ContextBuilder = Callable[[models.BaseModel, Context], Context]
 
 PHASE_PRECREATE = "precreate"
 PHASE_POSTCREATE = "postcreate"
@@ -71,6 +84,6 @@
 
 
 def build_context(
-    instance: models.Model | None,
-    ctx: Mapping | None,
+    instance: models.BaseModel | None,
+    ctx: Context,
     extend: ContextBuilder | None,
@@ -76,5 +89,5 @@
     extend: ContextBuilder | None,
-) -> dict | None:
+) -> Context:
     if instance is None:
         return ctx
     if extend:
@@ -82,7 +95,9 @@
             ctx = {}
         else:
             ctx = dict(ctx)
-        ctx.update(extend(instance))
+        extended = extend(instance, None)
+        if extended is not None:
+            ctx.update(extended)
     return ctx
 
 
@@ -86,8 +101,13 @@
     return ctx
 
 
+class NotAnInstanceGetterException(Exception):
+    def __init__(self):
+        super().__init__("Not an instance getter")
+
+
 class Converter:
     """Base converter class.
     It does not actually convert anything.
     """
 
@@ -89,11 +109,12 @@
 class Converter:
     """Base converter class.
     It does not actually convert anything.
     """
 
-    def odoo_to_message(
-        self, instance: models.Model, ctx: Mapping | None = None
-    ) -> Any:
+    def __init__(self):
+        self._validation: Validation = Validation.SKIP
+
+    def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any:
         """From an instance, this method returns a matching value for the
         message field.
         :param instance: an instance of an Odoo model
@@ -107,5 +128,5 @@
         odoo_env: api.Environment,
         phase: str,
         message_value: Any,
-        instance: models.Model,
+        instance: models.BaseModel,
         value_present: bool = True,
@@ -111,5 +132,5 @@
         value_present: bool = True,
-    ) -> dict:
+    ) -> dict | SkipType:
         """From a message, returns a dict.
         Only field whose values are changed are included in the returned dict.
         :param odoo_env: odoo environment
@@ -123,7 +144,7 @@
         """
         return {}
 
-    @classmethod
-    def is_instance_getter(cls) -> bool:
+    @property
+    def is_instance_getter(self) -> bool:
         return False
 
@@ -128,5 +149,12 @@
         return False
 
+    # XXX should that be moved to a different class, like PostHookConverter
+    def get_instance(
+        self, odoo_env: api.Environment, message_data
+    ) -> models.BaseModel | NewinstanceType | None:
+        """Return an instance of a model. Check is_instance_getter before calling"""
+        raise NotAnInstanceGetterException()
+
     def get__type__(self) -> set[str]:
         """Indicate if this converter is associated to several __type__.
         If so, it will be called with incoming messages associated to them.
@@ -136,7 +164,7 @@
     @property
     def validator(self) -> Validator | None:
         """A validator to use for validation of created messages"""
-        return self._validator
+        return self._get_validator()
 
     @validator.setter
     def validator(self, value: Validator | None) -> None:
@@ -140,6 +168,12 @@
 
     @validator.setter
     def validator(self, value: Validator | None) -> None:
+        self._set_validator(value)
+
+    def _get_validator(self) -> Validator | None:
+        return self._validator
+
+    def _set_validator(self, value: Validator | None) -> None:
         if value is None:
             self._validator = None
         else:
@@ -151,6 +185,13 @@
                 )
 
     @property
-    def validation(self) -> str:
+    def validation(self) -> Validation:
+        return self._get_validation()
+
+    @validation.setter
+    def validation(self, value: Validation) -> None:
+        self._set_validation(value)
+
+    def _get_validation(self) -> Validation:
         return self._validation
 
@@ -155,8 +196,7 @@
         return self._validation
 
-    @validation.setter
-    def validation(self, value: str) -> None:
+    def _set_validation(self, value: Validation) -> None:
         """Define if validation should be done"""
         assert value is not None
         self._validation = value
 
@@ -159,6 +199,21 @@
         """Define if validation should be done"""
         assert value is not None
         self._validation = value
 
+    @property
+    def possible_datatypes(self) -> set[str]:
+        """Possible values for datatype."""
+        # A set, as for get___type__, to allow switch to handle different messages.
+        return set()
+
+    def odoo_datatype(self, instance: models.BaseModel) -> str | None:
+        return None
+
+
+class PostHookConverter(Converter, metaclass=ABCMeta):
+    @abstractmethod
+    def post_hook(self, instance: models.BaseModel, message_data):
+        """Post hook"""
+
 
 class Readonly(Converter):
@@ -163,4 +218,18 @@
 
 class Readonly(Converter):
-    def __init__(self, conv):
+    def __init__(self, conv: Converter):
+        super().__init__()
+        self._conv = conv
+
+    def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any:
+        return self._conv.odoo_to_message(instance, ctx)
+
+    def odoo_datatype(self, instance: models.BaseModel) -> str | None:
+        return self._conv.odoo_datatype(instance)
+
+
+class Writeonly(Converter):
+    """A converter that only convert to odoo but does nothing from odoo."""
+
+    def __init__(self, conv: Converter):
         super().__init__()
@@ -166,3 +235,3 @@
         super().__init__()
-        self.conv = conv
+        self._conv = conv
 
@@ -168,6 +237,28 @@
 
-    def odoo_to_message(self, instance: models.Model, ctx: dict | None = None) -> Any:
-        return self.conv.odoo_to_message(instance, ctx)
+    def message_to_odoo(
+        self,
+        odoo_env: api.Environment,
+        phase: str,
+        message_value: Any,
+        instance: models.BaseModel,
+        value_present: bool = True,
+    ) -> dict | SkipType:
+        return self._conv.message_to_odoo(
+            odoo_env, phase, message_value, instance, value_present
+        )
+
+    @property
+    def is_instance_getter(self) -> bool:
+        return self._conv.is_instance_getter
+
+    def get_instance(
+        self, odoo_env: api.Environment, message_data
+    ) -> models.BaseModel | NewinstanceType | None:
+        return self._conv.get_instance(odoo_env, message_data)
+
+    @property
+    def possible_datatypes(self) -> set[str]:
+        return self._conv.possible_datatypes
 
 
 class Computed(Converter):
@@ -171,7 +262,7 @@
 
 
 class Computed(Converter):
-    def __init__(self, from_odoo: Callable[[models.Model, Mapping | None], Any]):
+    def __init__(self, from_odoo: Callable[[models.BaseModel, Context], Any]):
         self.from_odoo = from_odoo
 
         sig = inspect.signature(from_odoo)
@@ -182,7 +273,5 @@
                 f"{self.from_odoo_arg_count}"
             )
 
-    def odoo_to_message(
-        self, instance: models.Model, ctx: Mapping | None = None
-    ) -> Any:
+    def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any:
         if self.from_odoo_arg_count == 1:
@@ -188,6 +277,6 @@
         if self.from_odoo_arg_count == 1:
-            return self.from_odoo(instance)
+            return self.from_odoo(instance, None)
         return self.from_odoo(instance, ctx)
 
 
 class Constant(Converter):
@@ -190,7 +279,6 @@
         return self.from_odoo(instance, ctx)
 
 
 class Constant(Converter):
-    def __init__(self, value):
-        self.value = value
+    """When building messages, this converter return a constant value."""
 
@@ -196,8 +284,9 @@
 
-    def odoo_to_message(
-        self, instance: models.Model, ctx: Mapping | None = None
-    ) -> Any:
-        return self.value
+    def __init__(self, value: Any):
+        self._value = value
+
+    def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any:
+        return self._value
 
 
 def message_to_odoo(
@@ -206,7 +295,7 @@
     model_name: str,
     converter: Converter,
     operation: str | None = None,
-) -> models.Model:
+) -> models.BaseModel:
     """
 
     :param odoo_env: an Odoo environment
@@ -218,6 +307,7 @@
        :py:meth:odoo.addons.Converter.get_instance
     :return:
     """
+    instance: NewinstanceType | models.BaseModel
     if operation == OPERATION_CREATION:
         instance = Newinstance
     else:
@@ -228,6 +318,9 @@
         changes = converter.message_to_odoo(
             odoo_env, PHASE_PRECREATE, payload, instance
         )
+        if isinstance(changes, SkipType):
+            return odoo_env[model_name]
+
         instance = odoo_env[model_name].create(changes)
         changes = converter.message_to_odoo(
             odoo_env, PHASE_POSTCREATE, payload, instance
@@ -238,6 +331,9 @@
         operation is None and not instance or instance is Newinstance
     ):
         changes = converter.message_to_odoo(odoo_env, PHASE_UPDATE, payload, instance)
+        if isinstance(changes, SkipType):
+            return odoo_env[model_name]
+
         if changes:
             instance.write(changes)
     if hasattr(converter, "post_hook"):
diff --git a/doc/conf.py b/doc/conf.py
index eb1932fd59fdac405a8bafefd21995c78be45b23_ZG9jL2NvbmYucHk=..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_ZG9jL2NvbmYucHk= 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -15,7 +15,7 @@
 from importlib.metadata import version as import_version
 
 import odoo  # type: ignore[import-untyped]
-from odoo_scripts.config import Configuration
+from odoo_scripts.config import Configuration  # type: ignore[import-untyped]
 
 # If extensions (or modules to document with autodoc) are in another directory,
 # add these directories to sys.path here. If the directory is relative to the
@@ -158,5 +158,5 @@
     os.path.dirname(os.path.abspath(odoo.__file__))
 )
 # sphinxodooautodoc_addons_path : List of paths were Odoo addons to load are located
-c = None
+c: Configuration | None = None
 # find setup file of superproject, if any
@@ -162,5 +162,10 @@
 # find setup file of superproject, if any
-directory = os.path.dirname(os.getenv("PWD"))
+pwd = os.getenv("PWD")
+directory: str | None
+if pwd is not None:
+    directory = os.path.dirname(pwd)
+else:
+    directory = None
 while not c and directory:
     setup_path = os.path.join(directory, "setup.cfg")
     if os.path.isfile(setup_path):
@@ -164,9 +169,9 @@
 while not c and directory:
     setup_path = os.path.join(directory, "setup.cfg")
     if os.path.isfile(setup_path):
-        c = configparser.ConfigParser()
-        c.read(setup_path)
-        if c.has_section("odoo_scripts"):
+        setup_cfg = configparser.ConfigParser()
+        setup_cfg.read(setup_path)
+        if setup_cfg.has_section("odoo_scripts"):
             # reload with odoo_scripts
             c = Configuration(directory)
         else:
@@ -182,7 +187,8 @@
 if c:
     addon_dirs = set(os.path.dirname(path) for path in c.modules)
 
-    for line in addon_dirs:
-        sphinxodooautodoc_addons_path.append(os.path.join(directory, line))
+    if directory is not None:
+        for line in addon_dirs:
+            sphinxodooautodoc_addons_path.append(os.path.join(directory, line))
 else:
     # add this directory top dir
@@ -187,8 +193,7 @@
 else:
     # add this directory top dir
-    sphinxodooautodoc_addons_path.append(
-        os.path.dirname(os.path.dirname(os.getenv("PWD")))
-    )
+    if pwd is not None:
+        sphinxodooautodoc_addons_path.append(os.path.dirname(os.path.dirname(pwd)))
     other_addons = os.getenv("ODOO_ADDONS_PATH", default=None)
     if other_addons:
         for addon_path in other_addons.split(","):
diff --git a/field.py b/field.py
index eb1932fd59fdac405a8bafefd21995c78be45b23_ZmllbGQucHk=..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_ZmllbGQucHk= 100644
--- a/field.py
+++ b/field.py
@@ -20,6 +20,6 @@
 
 import datetime
 from collections.abc import Callable
-from typing import Any
+from typing import Any, Literal
 
 import pytz
@@ -24,4 +24,4 @@
 
 import pytz
-from odoo import api, models
+from odoo import api, models  # type: ignore[import-untyped]
 
@@ -27,5 +27,5 @@
 
-from .base import PHASE_POSTCREATE, Converter, Newinstance, Skip
+from .base import PHASE_POSTCREATE, Context, Converter, Newinstance, Skip, SkipType
 
 
 class Field(Converter):
@@ -61,7 +61,7 @@
         self.default = default
         self.send_empty = send_empty or required_blank_value is not None
         self.required_blank_value = required_blank_value
-        self._blank_value = None
+        self._blank_value: Literal[False, "", 0] | list | float | None = None
         self._message_formatter = message_formatter
         self._odoo_formatter = odoo_formatter
 
@@ -87,7 +87,7 @@
 
         return self._blank_value
 
-    def odoo_to_message(self, instance: models.Model, ctx: dict | None = None) -> Any:
+    def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any:
         value = False
         # could be empty due to chaining converter on a many2one without value
         # for example
@@ -120,5 +120,5 @@
         odoo_env: api.Environment,
         phase: str,
         message_value: Any,
-        instance: models.Model,
+        instance: models.BaseModel,
         value_present: bool = True,
@@ -124,5 +124,5 @@
         value_present: bool = True,
-    ) -> dict:
+    ) -> dict | SkipType:
         if phase == PHASE_POSTCREATE:
             return {}
         if not value_present:
@@ -133,7 +133,7 @@
         # do not include value if already the same
         if instance and instance is not Newinstance:
             value = self.odoo_to_message(instance)
-            if value is Skip or value == message_value:
+            if isinstance(value, SkipType) or value == message_value:
                 return {}
         if self._odoo_formatter:
             message_value = self._odoo_formatter(message_value)
@@ -161,7 +161,7 @@
         super().__init__(field_name, default, send_empty, required_blank_value)
         self._language = language
 
-    def _lazy_dicts(self, instance: models.Model):
+    def _lazy_dicts(self, instance: models.BaseModel):
         if not hasattr(self, "_lazy_dict_odoo_to_message"):
             description_selection = (
                 instance.with_context(lang=self._language)
@@ -173,7 +173,7 @@
                 (b, a) for a, b in description_selection
             )
 
-    def odoo_to_message(self, instance: models.Model, ctx: dict | None = None) -> Any:
+    def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any:
         value = super().odoo_to_message(instance, ctx)
         if value:
             self._lazy_dicts(instance)
@@ -185,5 +185,5 @@
         odoo_env: api.Environment,
         phase: str,
         message_value: Any,
-        instance: models.Model,
+        instance: models.BaseModel,
         value_present: bool = True,
@@ -189,5 +189,5 @@
         value_present: bool = True,
-    ) -> dict:
+    ) -> dict | SkipType:
         message = super().message_to_odoo(
             odoo_env, phase, message_value, instance, value_present
         )
@@ -191,7 +191,7 @@
         message = super().message_to_odoo(
             odoo_env, phase, message_value, instance, value_present
         )
-        if self.field_name in message:
+        if not isinstance(message, SkipType) and self.field_name in message:
             self._lazy_dicts(instance)
             message[self.field_name] = self._lazy_dict_message_to_odoo.get(
                 message[self.field_name]
diff --git a/keyfield.py b/keyfield.py
index eb1932fd59fdac405a8bafefd21995c78be45b23_a2V5ZmllbGQucHk=..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_a2V5ZmllbGQucHk= 100644
--- a/keyfield.py
+++ b/keyfield.py
@@ -20,5 +20,5 @@
 
 from typing import Any
 
-from odoo import models
+from odoo import api, models  # type: ignore[import-untyped]
 
@@ -24,5 +24,5 @@
 
-from .base import Converter
+from .base import Context, Converter, NewinstanceType
 
 
 class KeyField(Converter):
@@ -39,6 +39,6 @@
         self.model_name = model_name
         self.lookup_limit = 1 if limit_lookup else None
 
-    def odoo_to_message(self, instance: models.Model, ctx: dict | None = None) -> Any:
+    def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any:
         return getattr(instance, self.field_name)
 
@@ -43,6 +43,8 @@
         return getattr(instance, self.field_name)
 
-    def get_instance(self, odoo_env, message_data):
+    def get_instance(
+        self, odoo_env: api.Environment, message_data
+    ) -> models.BaseModel | NewinstanceType | None:
         instance = odoo_env[self.model_name].search(
             [(self.field_name, "=", message_data)], limit=self.lookup_limit
         )
@@ -50,6 +52,6 @@
             instance.ensure_one()
         return instance
 
-    @classmethod
-    def is_instance_getter(cls) -> bool:
+    @property
+    def is_instance_getter(self) -> bool:
         return True
diff --git a/list.py b/list.py
index eb1932fd59fdac405a8bafefd21995c78be45b23_bGlzdC5weQ==..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_bGlzdC5weQ== 100644
--- a/list.py
+++ b/list.py
@@ -20,5 +20,5 @@
 
 from typing import Any
 
-from odoo import models
+from odoo import api, models  # type: ignore[import-untyped]
 
@@ -24,5 +24,5 @@
 
-from .base import ContextBuilder, Converter, Skip, build_context
+from .base import Context, ContextBuilder, Converter, SkipType, build_context
 
 
 class List(Converter):
@@ -37,10 +37,10 @@
         self._converters = converters
         self.context = context
 
-    def odoo_to_message(self, instance: models.Model, ctx: dict | None = None) -> Any:
+    def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any:
         ctx = build_context(instance, ctx, self.context)
 
         message_data = []
 
         for converter in self._converters:
             value = converter.odoo_to_message(instance, ctx)
@@ -41,13 +41,13 @@
         ctx = build_context(instance, ctx, self.context)
 
         message_data = []
 
         for converter in self._converters:
             value = converter.odoo_to_message(instance, ctx)
-            if value is not Skip:
+            if not isinstance(value, SkipType):
                 message_data.append(value)
 
         return message_data
 
     def message_to_odoo(
         self,
@@ -48,8 +48,8 @@
                 message_data.append(value)
 
         return message_data
 
     def message_to_odoo(
         self,
-        odoo_env,
+        odoo_env: api.Environment,
         phase: str,
@@ -55,4 +55,4 @@
         phase: str,
-        message_value,
-        instance: models.Model,
+        message_value: Any,
+        instance: models.BaseModel,
         value_present: bool = True,
@@ -58,3 +58,10 @@
         value_present: bool = True,
-    ) -> dict:
-        return {}
+    ) -> dict | SkipType:
+        result = {}
+        for i, converter in enumerate(self._converters):
+            new_values = converter.message_to_odoo(
+                odoo_env, phase, message_value[i], instance, value_present
+            )
+            if not isinstance(new_values, SkipType):
+                result.update(new_values)
+        return result
diff --git a/mail_template.py b/mail_template.py
index eb1932fd59fdac405a8bafefd21995c78be45b23_bWFpbF90ZW1wbGF0ZS5weQ==..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_bWFpbF90ZW1wbGF0ZS5weQ== 100644
--- a/mail_template.py
+++ b/mail_template.py
@@ -1,7 +1,7 @@
 ##############################################################################
 #
 #    Converter Odoo module
-#    Copyright © 2020 XCG Consulting <https://xcg-consulting.fr>
+#    Copyright © 2020, 2024 XCG Consulting <https://xcg-consulting.fr>
 #
 #    This program is free software: you can redistribute it and/or modify
 #    it under the terms of the GNU Affero General Public License as
@@ -17,7 +17,6 @@
 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
 #
 ##############################################################################
-
 import ast
 from typing import Any
 
@@ -21,5 +20,5 @@
 import ast
 from typing import Any
 
-from odoo import models
+from odoo import models  # type: ignore[import-untyped]
 
@@ -25,4 +24,4 @@
 
-from . import base
+from .base import Context, Converter
 
 
@@ -27,5 +26,5 @@
 
 
-class MailTemplate(base.Converter):
+class MailTemplate(Converter):
     """This converter wraps ``mail.template::_render_template``.
     Multiple records are allowed but ``mail.template::_render_template`` still
@@ -30,9 +29,11 @@
     """This converter wraps ``mail.template::_render_template``.
     Multiple records are allowed but ``mail.template::_render_template`` still
-    runs once per record; to accomodate, we provide ``ctx["records"]``.
+    runs once per record; to accommodate, we provide ``ctx["records"]``.
+
+    Using this converter requires the mail module to be installed.
     """
 
     def __init__(self, template: str, post_eval: bool = False):
         self.template = template
         self.post_eval = post_eval
 
@@ -33,10 +34,10 @@
     """
 
     def __init__(self, template: str, post_eval: bool = False):
         self.template = template
         self.post_eval = post_eval
 
-    def odoo_to_message(self, records: models.Model, ctx: dict | None = None) -> Any:
+    def odoo_to_message(self, records: models.BaseModel, ctx: Context = None) -> Any:
         value = (
             records.env["mail.template"]
             .with_context(records=records, safe=True)
diff --git a/model.py b/model.py
index eb1932fd59fdac405a8bafefd21995c78be45b23_bW9kZWwucHk=..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_bW9kZWwucHk= 100644
--- a/model.py
+++ b/model.py
@@ -1,7 +1,7 @@
 ##############################################################################
 #
 #    Converter Odoo module
-#    Copyright © 2020 XCG Consulting <https://xcg-consulting.fr>
+#    Copyright © 2020, 2024 XCG Consulting <https://xcg-consulting.fr>
 #
 #    This program is free software: you can redistribute it and/or modify
 #    it under the terms of the GNU Affero General Public License as
@@ -17,8 +17,7 @@
 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
 #
 ##############################################################################
-
 import logging
 import traceback
 from collections import OrderedDict
 from collections.abc import Iterable, Mapping
@@ -21,6 +20,6 @@
 import logging
 import traceback
 from collections import OrderedDict
 from collections.abc import Iterable, Mapping
-from typing import Any
+from typing import Any, Final
 
@@ -26,5 +25,6 @@
 
-from odoo import _, api, models
-from odoo.exceptions import UserError
+import fastjsonschema  # type: ignore[import-untyped]
+from odoo import _, api, models  # type: ignore[import-untyped]
+from odoo.exceptions import UserError  # type: ignore[import-untyped]
 
 from .base import (
@@ -29,6 +29,7 @@
 
 from .base import (
+    Context,
     ContextBuilder,
     Converter,
     Newinstance,
     NewinstanceType,
@@ -31,7 +32,8 @@
     ContextBuilder,
     Converter,
     Newinstance,
     NewinstanceType,
-    Skip,
+    PostHookConverter,
+    SkipType,
     build_context,
 )
@@ -36,7 +38,7 @@
     build_context,
 )
-from .validate import VALIDATION_SKIP, VALIDATION_STRICT, Validator
+from .validate import NotInitialized, Validation, Validator
 
 _logger = logging.getLogger(__name__)
 
 
@@ -39,9 +41,18 @@
 
 _logger = logging.getLogger(__name__)
 
 
-class Model(Converter):
+class IncorrectTypeException(Exception):
+    """__type__ is in the message is not the same as the expected value"""
+
+
+class MissingRequiredValidatorException(Exception):
+    def __init__(self):
+        super().__init__("Strict validation without validator")
+
+
+class Model(PostHookConverter):
     """A converter that takes a dict of key, used when a message has values"""
 
     def __init__(
         self,
@@ -44,11 +55,10 @@
     """A converter that takes a dict of key, used when a message has values"""
 
     def __init__(
         self,
-        __type__: str,
         converters: Mapping[str, Converter],
         json_schema: str | None = None,
         # The validator is usually not given at this point but is common
         # throughout a project. That’s why it is a property
         validator: Validator | None = None,
         merge_with: Iterable[Converter] | None = None,
@@ -49,8 +59,8 @@
         converters: Mapping[str, Converter],
         json_schema: str | None = None,
         # The validator is usually not given at this point but is common
         # throughout a project. That’s why it is a property
         validator: Validator | None = None,
         merge_with: Iterable[Converter] | None = None,
-        validation: str = VALIDATION_SKIP,
+        validation: Validation = Validation.SKIP,
         context: ContextBuilder | None = None,
@@ -56,2 +66,4 @@
         context: ContextBuilder | None = None,
+        datatype: str | None = None,
+        __type__: str | None = None,
     ):
@@ -57,2 +69,5 @@
     ):
+        """
+        :param datatype: datatype to use. Usually used with None __type__.
+        """
         super().__init__()
@@ -58,3 +73,3 @@
         super().__init__()
-        self._type: str = __type__
+        self._type: str | None = __type__
         self._converters: Mapping[str, Converter] = converters
@@ -60,3 +75,3 @@
         self._converters: Mapping[str, Converter] = converters
-        self._post_hooks_converters_key: list[str] = []
+        self._post_hooks_converters: dict[str, PostHookConverter] = {}
         self._jsonschema: str | None = json_schema
@@ -62,5 +77,5 @@
         self._jsonschema: str | None = json_schema
-        self._get_instance: Converter = None
+        self._get_instance: str | None = None
         """First converter whose `is_instance_getter` is true if any"""
         self.merge_with: Iterable[Converter] | None = merge_with
         self.context: ContextBuilder | None = context
@@ -64,5 +79,5 @@
         """First converter whose `is_instance_getter` is true if any"""
         self.merge_with: Iterable[Converter] | None = merge_with
         self.context: ContextBuilder | None = context
-        self.validator: Validator | None = validator
+        self.validator = validator
         self.validation = validation
@@ -68,3 +83,4 @@
         self.validation = validation
+        self._datatype: Final[str | None] = datatype
 
         for key, converter in converters.items():
@@ -69,4 +85,4 @@
 
         for key, converter in converters.items():
-            if self._get_instance is None and converter.is_instance_getter():
+            if self._get_instance is None and converter.is_instance_getter:
                 self._get_instance = key
@@ -72,4 +88,4 @@
                 self._get_instance = key
-            if hasattr(converter, "post_hook"):
-                self._post_hooks_converters_key.append(key)
+            if isinstance(converter, PostHookConverter):
+                self._post_hooks_converters[key] = converter
 
@@ -75,5 +91,5 @@
 
-    def odoo_to_message(self, instance: models.Model, ctx: dict | None = None) -> Any:
+    def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any:
         ctx = build_context(instance, ctx, self.context)
 
         message_data = {}
@@ -77,7 +93,7 @@
         ctx = build_context(instance, ctx, self.context)
 
         message_data = {}
-        if self._type:
+        if self._type is not None:
             message_data["__type__"] = self._type
 
         errors = []
@@ -89,7 +105,7 @@
                     {"key": key, "traceback": "".join(traceback.format_exception(e))}
                 )
                 continue
-            if value is not Skip:
+            if not isinstance(value, SkipType):
                 message_data[key] = value
         if len(errors) != 0:
             formatted_errors = "\n\n".join(
@@ -105,7 +121,7 @@
         if self.merge_with:
             for conv in self.merge_with:
                 value = conv.odoo_to_message(instance, ctx)
-                if value is Skip:
+                if isinstance(value, SkipType):
                     continue
                 message_data.update(value)
 
@@ -109,13 +125,16 @@
                     continue
                 message_data.update(value)
 
-        if self.validation != VALIDATION_SKIP and self._jsonschema is not None:
-            try:
-                self.validator.validate(self._jsonschema, message_data)
-            except Exception as exception:
-                _logger.warning("Validation failed", exc_info=1)
-                if self.validation == VALIDATION_STRICT:
-                    raise exception
+        if self.validation != Validation.SKIP and self._jsonschema is not None:
+            if self.validator:
+                try:
+                    self.validator.validate(self._jsonschema, message_data)
+                except (NotInitialized, fastjsonschema.JsonSchemaException):
+                    _logger.warning("Validation failed", exc_info=True)
+                    if self.validation == Validation.STRICT:
+                        raise
+            elif self.validation == Validation.STRICT:
+                raise MissingRequiredValidatorException()
 
         return message_data
 
@@ -124,5 +143,5 @@
         odoo_env: api.Environment,
         phase: str,
         message_value: Any,
-        instance: models.Model,
+        instance: models.BaseModel,
         value_present: bool = True,
@@ -128,4 +147,4 @@
         value_present: bool = True,
-    ) -> dict:
-        values = OrderedDict()
+    ) -> dict | SkipType:
+        values: dict[str, Any] = OrderedDict()
 
@@ -131,9 +150,20 @@
 
-        if self._type and message_value["__type__"] != self._type:
-            raise Exception(
+        if self.validation != Validation.SKIP and self._jsonschema is not None:
+            if self.validator:
+                try:
+                    self.validator.validate(self._jsonschema, message_value)
+                except (NotInitialized, fastjsonschema.JsonSchemaException):
+                    _logger.warning("Validation failed", exc_info=True)
+                    if self.validation == Validation.STRICT:
+                        raise
+            elif self.validation == Validation.STRICT:
+                raise MissingRequiredValidatorException()
+
+        if self._type is not None and message_value["__type__"] != self._type:
+            raise IncorrectTypeException(
                 "Expected __type__ {}, found {}".format(
                     self._type, message_value["__type__"]
                 )
             )
         for key in self._converters:
             value = message_value.get(key, None) if message_value else None
@@ -134,15 +164,13 @@
                 "Expected __type__ {}, found {}".format(
                     self._type, message_value["__type__"]
                 )
             )
         for key in self._converters:
             value = message_value.get(key, None) if message_value else None
-            values.update(
-                self._converters[key].message_to_odoo(
-                    odoo_env,
-                    phase,
-                    value,
-                    instance,
-                    message_value and key in message_value,
-                )
+            attribute_vals = self._converters[key].message_to_odoo(
+                odoo_env,
+                phase,
+                value,
+                instance,
+                message_value and key in message_value,
             )
@@ -148,6 +176,9 @@
             )
+            if isinstance(attribute_vals, SkipType):
+                continue
+            values.update(attribute_vals)
         if self.merge_with:
             for conv in self.merge_with:
                 value = conv.message_to_odoo(
                     odoo_env, phase, message_value, instance, value_present
                 )
@@ -149,11 +180,11 @@
         if self.merge_with:
             for conv in self.merge_with:
                 value = conv.message_to_odoo(
                     odoo_env, phase, message_value, instance, value_present
                 )
-                if value is Skip:
+                if isinstance(value, SkipType):
                     continue
                 values.update(value)
 
         return values
 
@@ -155,10 +186,11 @@
                     continue
                 values.update(value)
 
         return values
 
+    @property
     def is_instance_getter(self) -> bool:
         return self._get_instance is not None
 
     def get_instance(
         self, odoo_env: api.Environment, message_data
@@ -160,9 +192,9 @@
     def is_instance_getter(self) -> bool:
         return self._get_instance is not None
 
     def get_instance(
         self, odoo_env: api.Environment, message_data
-    ) -> None | models.Model | NewinstanceType:
+    ) -> models.BaseModel | NewinstanceType | None:
         """:return: an instance linked to the converter, if any"""
         if self._get_instance:
             instance = self._converters[self._get_instance].get_instance(
@@ -173,6 +205,6 @@
             return instance
         return None
 
-    def post_hook(self, instance: models.Model, message_data):
-        for key in self._post_hooks_converters_key:
+    def post_hook(self, instance: models.BaseModel, message_data):
+        for key in self._post_hooks_converters:
             if key in message_data:
@@ -178,8 +210,8 @@
             if key in message_data:
-                self._converters[key].post_hook(instance, message_data[key])
+                self._post_hooks_converters[key].post_hook(instance, message_data[key])
         if self.merge_with:
             for converter in self.merge_with:
                 if hasattr(converter, "post_hook"):
                     converter.post_hook(instance, message_data)
 
     def get__type__(self) -> set[str]:
@@ -180,7 +212,17 @@
         if self.merge_with:
             for converter in self.merge_with:
                 if hasattr(converter, "post_hook"):
                     converter.post_hook(instance, message_data)
 
     def get__type__(self) -> set[str]:
-        return {self._type}
+        return set() if self._type is None else {self._type}
+
+    @property
+    def possible_datatypes(self) -> set[str]:
+        result = set()
+        if self._datatype is not None:
+            result.add(self._datatype)
+        return result
+
+    def odoo_datatype(self, instance: models.BaseModel) -> str | None:
+        return self._datatype
diff --git a/models/ir_model_data.py b/models/ir_model_data.py
index eb1932fd59fdac405a8bafefd21995c78be45b23_bW9kZWxzL2lyX21vZGVsX2RhdGEucHk=..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_bW9kZWxzL2lyX21vZGVsX2RhdGEucHk= 100644
--- a/models/ir_model_data.py
+++ b/models/ir_model_data.py
@@ -19,4 +19,5 @@
 ##############################################################################
 
 import uuid
+from typing import Final
 
@@ -22,4 +23,4 @@
 
-from odoo import api, models
+from odoo import api, models  # type: ignore[import-untyped]
 
 # Xrefs are stored within "ir.model.data" with this module name.
@@ -24,5 +25,5 @@
 
 # Xrefs are stored within "ir.model.data" with this module name.
-_XREF_IMD_MODULE = "__converter__"
+_XREF_IMD_MODULE: Final[str] = "__converter__"
 
 
@@ -27,6 +28,6 @@
 
 
-class IrModelData(models.Model):
+class IrModelData(models.BaseModel):
     """Add xref tools.
     All done with the super-admin user to bypass security rules.
 
@@ -37,5 +38,5 @@
     _inherit = "ir.model.data"
 
     @api.model
-    def generate_name(self):
+    def generate_name(self, prefix: str = "") -> str:
         """Generate an xref for odoo record;
@@ -41,4 +42,5 @@
         """Generate an xref for odoo record;
-        It return a UUID from a string of 32 hex digit
+        :param prefix: prefix to use before the name.
+        :return: a UUID from a string of 32 hex digit
         """
 
@@ -43,5 +45,5 @@
         """
 
-        return uuid.uuid4().hex
+        return prefix + uuid.uuid4().hex
 
     @api.model
@@ -46,7 +48,12 @@
 
     @api.model
-    def object_to_module_and_name(self, record_set, module=_XREF_IMD_MODULE):
+    def object_to_module_and_name(
+        self,
+        record_set: models.BaseModel,
+        module: str | None = _XREF_IMD_MODULE,
+        prefix: str = "",
+    ) -> tuple[str, str]:
         """Retrieve an xref pointing to the specified Odoo record; create one
         when missing.
         :param module: Name of the module to use. None to use any name, if no
         xmlid exists "" will be used as the module name.
@@ -49,9 +56,8 @@
         """Retrieve an xref pointing to the specified Odoo record; create one
         when missing.
         :param module: Name of the module to use. None to use any name, if no
         xmlid exists "" will be used as the module name.
-        :type module: Optional[str]
-        :rtype: Tuple[str, str]
+        :param prefix: prefix to use before the name.
         :return: tuple module and name
         """
         record_set.ensure_one()
@@ -62,6 +68,8 @@
         ]
         if module is not None:
             domain.append(("module", "=", module))
+        if prefix:
+            domain.append(("name", "=like", f"{prefix}%"))
 
         # Find an existing xref. See class docstring for details.
         imd = self.sudo().search(domain, limit=1)
@@ -69,10 +77,10 @@
             return imd.module, imd.name
 
         # Could not find an existing xref; create one.
-        name = self.generate_name()
+        name = self.generate_name(prefix)
         if module is None:
             module = ""
         self.set_xmlid(record_set, name, module)
         return module, name
 
     @api.model
@@ -73,11 +81,16 @@
         if module is None:
             module = ""
         self.set_xmlid(record_set, name, module)
         return module, name
 
     @api.model
-    def object_to_xmlid(self, record_set, module=_XREF_IMD_MODULE):
+    def object_to_xmlid(
+        self,
+        record_set: models.BaseModel,
+        module: str | None = _XREF_IMD_MODULE,
+        prefix: str = "",
+    ) -> str:
         """Retrieve an xref pointing to the specified Odoo record; create one
         when missing.
         :param module: Name of the module to use. None to use any name, if no
         xmlid exists "" will be used as the module name.
@@ -80,8 +93,7 @@
         """Retrieve an xref pointing to the specified Odoo record; create one
         when missing.
         :param module: Name of the module to use. None to use any name, if no
         xmlid exists "" will be used as the module name.
-        :type module: Optional[str]
-        :rtype: xmlid
+        :param prefix: prefix to use before the name.
         """
         return "{0[0]}.{0[1]}".format(
@@ -86,8 +98,8 @@
         """
         return "{0[0]}.{0[1]}".format(
-            self.object_to_module_and_name(record_set, module)
+            self.object_to_module_and_name(record_set, module, prefix)
         )
 
     @api.model
     def set_xmlid(
         self,
@@ -89,8 +101,8 @@
         )
 
     @api.model
     def set_xmlid(
         self,
-        record_set: models.Model,
+        record_set: models.BaseModel,
         name: str,
         module: str = _XREF_IMD_MODULE,
@@ -95,6 +107,6 @@
         name: str,
         module: str = _XREF_IMD_MODULE,
-        only_when_missing: str = False,
+        only_when_missing: bool = False,
     ):
         """Save an external reference to the specified Odoo record.
         :param module: Name of the module to use.
diff --git a/py.typed b/py.typed
new file mode 100644
diff --git a/pyproject.toml b/pyproject.toml
index eb1932fd59fdac405a8bafefd21995c78be45b23_cHlwcm9qZWN0LnRvbWw=..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_cHlwcm9qZWN0LnRvbWw= 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,5 +1,6 @@
 [project]
 name = "odoo-addon-converter"
+description = "Odoo addon to convert records to and from plain data structures"
 dynamic = ["version"]
 readme = "README.rst"
 requires-python = "~=3.10"
@@ -42,6 +43,7 @@
 
 [tool.hatch.build.targets.wheel]
 include = [
+  "py.typed",
   "*.csv",
   "/i18n/",
   "/static/",
@@ -49,7 +51,8 @@
   "*.xml",
   "*.py",
   "*.svg",
-  "*.png"
+  "*.png",
+  "*.json"
 ]
 
 [tool.hatch.build.targets.wheel.sources]
@@ -57,24 +60,7 @@
 
 [tool.hatch.version]
 source = "vcs"
-
-[tool.isort]
-section-order = [
-  "future",
-  "standard-library",
-  "third-party",
-  "odoo",
-  "odoo-addons",
-  "first-party",
-  "local-folder"
-]
-
-[tool.isort.sections]
-"odoo" = ["odoo"]
-"odoo-addons" = ["odoo.addons"]
-
-[tool.ruff.lint.mccabe]
-max-complexity = 16
+fallback-version = "17.0.0.0.0"
 
 [tool.ruff]
 target-version = "py310"
@@ -92,3 +78,21 @@
 [tool.ruff.lint.per-file-ignores]
 "__init__.py" = ["F401", "I001"] # ignore unused and unsorted imports in __init__.py
 "__manifest__.py" = ["B018"] # useless expression
+
+[tool.ruff.lint.mccabe]
+max-complexity = 16
+
+[tool.isort]
+section-order = [
+  "future",
+  "standard-library",
+  "third-party",
+  "odoo",
+  "odoo-addons",
+  "first-party",
+  "local-folder"
+]
+
+[tool.isort.sections]
+"odoo" = ["odoo"]
+"odoo-addons" = ["odoo.addons"]
diff --git a/relation.py b/relation.py
index eb1932fd59fdac405a8bafefd21995c78be45b23_cmVsYXRpb24ucHk=..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_cmVsYXRpb24ucHk= 100644
--- a/relation.py
+++ b/relation.py
@@ -1,7 +1,7 @@
 ##############################################################################
 #
 #    Converter Odoo module
-#    Copyright © 2020 XCG Consulting <https://xcg-consulting.fr>
+#    Copyright © 2020, 2024 XCG Consulting <https://xcg-consulting.fr>
 #
 #    This program is free software: you can redistribute it and/or modify
 #    it under the terms of the GNU Affero General Public License as
@@ -17,8 +17,7 @@
 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
 #
 ##############################################################################
-
 import logging
 from collections.abc import Callable
 from typing import Any
 
@@ -21,6 +20,6 @@
 import logging
 from collections.abc import Callable
 from typing import Any
 
-from odoo import api, models
+from odoo import api, models  # type: ignore[import-untyped]
 
@@ -26,5 +25,13 @@
 
-from .base import ContextBuilder, Converter, NewinstanceType, Skip, build_context
+from .base import (
+    Context,
+    ContextBuilder,
+    Converter,
+    NewinstanceType,
+    Skip,
+    SkipType,
+    build_context,
+)
 from .field import Field
 
 _logger = logging.getLogger(__name__)
@@ -34,7 +41,7 @@
     def __init__(
         self,
         field_name: str,
-        model_name: str,
+        model_name: str | None,
         converter: Converter,
         send_empty: bool = True,
         context: ContextBuilder | None = None,
@@ -45,7 +52,7 @@
         self._send_empty = send_empty
         self.context = context
 
-    def odoo_to_message(self, instance: models.Model, ctx: dict | None = None) -> Any:
+    def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any:
         ctx = build_context(instance, ctx, self.context)
         # do not use super, otherwise if empty, will convert that
         relation_instance = getattr(instance, self.field_name)
@@ -61,5 +68,5 @@
         odoo_env: api.Environment,
         phase: str,
         message_value: Any,
-        instance: models.Model,
+        instance: models.BaseModel,
         value_present: bool = True,
@@ -65,5 +72,5 @@
         value_present: bool = True,
-    ) -> dict:
+    ) -> dict | SkipType:
         if not value_present:
             return {}
 
@@ -82,8 +89,8 @@
         field_name: str,
         model_name: str | None,
         converter: Converter,
-        sortkey: None | Callable[[models.Model], bool] = None,
-        filtered: None | str | Callable[[models.Model], bool] = None,
+        sortkey: None | Callable[[models.BaseModel], bool] = None,
+        filtered: None | str | Callable[[models.BaseModel], bool] = None,
         context: ContextBuilder | None = None,
         limit: Any | None = None,
     ):
@@ -98,6 +105,6 @@
         self.context = context
         self.limit = limit
 
-    def odoo_to_message(self, instance: models.Model, ctx: dict | None = None) -> Any:
+    def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any:
         ctx = build_context(instance, ctx, self.context)
         value = super().odoo_to_message(instance, ctx)
@@ -102,7 +109,7 @@
         ctx = build_context(instance, ctx, self.context)
         value = super().odoo_to_message(instance, ctx)
-        if value is Skip:
-            return Skip
+        if isinstance(value, SkipType):
+            return value
         if self.filtered:
             value = value.filtered(self.filtered)
         if self.sortkey:
@@ -113,7 +120,7 @@
         return [
             m
             for m in (self.converter.odoo_to_message(r, ctx) for r in value)
-            if m is not Skip
+            if not isinstance(m, SkipType)
         ]
 
     def message_to_odoo(
@@ -121,5 +128,5 @@
         odoo_env: api.Environment,
         phase: str,
         message_value: Any,
-        instance: models.Model,
+        instance: models.BaseModel,
         value_present: bool = True,
@@ -125,5 +132,5 @@
         value_present: bool = True,
-    ) -> dict:
+    ) -> dict | SkipType:
         # if not present or value is None, do not update the values.
         if not value_present or message_value is None:
             return {}
@@ -151,7 +158,7 @@
         model_name: str | None,
         key_converter: Converter,
         value_converter: Converter,
-        filtered: None | str | Callable[[models.Model], bool] = None,
+        filtered: None | str | Callable[[models.BaseModel], bool] = None,
         context: ContextBuilder | None = None,
     ):
         """
@@ -164,6 +171,6 @@
         self.filtered = filtered
         self.context = context
 
-    def odoo_to_message(self, instance: models.Model, ctx: dict | None = None) -> Any:
+    def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any:
         ctx = build_context(instance, ctx, self.context)
         value = super().odoo_to_message(instance, ctx)
@@ -168,7 +175,7 @@
         ctx = build_context(instance, ctx, self.context)
         value = super().odoo_to_message(instance, ctx)
-        if value is Skip:
-            return Skip
+        if isinstance(value, SkipType):
+            return value
         if self.filtered:
             value = value.filtered(self.filtered)
         return {
@@ -180,7 +187,7 @@
                 )
                 for r in value
             )
-            if k is not Skip and v is not Skip
+            if not isinstance(k, SkipType) and not isinstance(v, SkipType)
         }
 
     def message_to_odoo(
@@ -188,5 +195,5 @@
         odoo_env: api.Environment,
         phase: str,
         message_value: Any,
-        instance: models.Model,
+        instance: models.BaseModel,
         value_present: bool = True,
@@ -192,5 +199,5 @@
         value_present: bool = True,
-    ) -> dict:
+    ) -> dict | SkipType:
         # if not present or value is None, do not update the values.
         if not value_present or message_value is None:
             return {}
diff --git a/switch.py b/switch.py
index eb1932fd59fdac405a8bafefd21995c78be45b23_c3dpdGNoLnB5..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_c3dpdGNoLnB5 100644
--- a/switch.py
+++ b/switch.py
@@ -1,7 +1,7 @@
 ##############################################################################
 #
 #    Converter Odoo module
-#    Copyright © 2020 XCG Consulting <https://xcg-consulting.fr>
+#    Copyright © 2020, 2024, 2025 XCG Consulting <https://xcg-consulting.fr>
 #
 #    This program is free software: you can redistribute it and/or modify
 #    it under the terms of the GNU Affero General Public License as
@@ -17,7 +17,6 @@
 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
 #
 ##############################################################################
-
-from collections.abc import Callable, Mapping
+from collections.abc import Callable
 from typing import Any
 
@@ -22,4 +21,4 @@
 from typing import Any
 
-from odoo import api, models
+from odoo import api, models  # type: ignore[import-untyped]
 
@@ -25,5 +24,13 @@
 
-from .base import ContextBuilder, Converter, Skip, SkipType
-from .validate import VALIDATION_SKIP, Validator
+from .base import (
+    Context,
+    ContextBuilder,
+    Converter,
+    NewinstanceType,
+    PostHookConverter,
+    Skip,
+    SkipType,
+)
+from .validate import Validation, Validator
 
 
@@ -28,6 +35,6 @@
 
 
-class Switch(Converter):
+class Switch(PostHookConverter):
     """A converter to handle switch cases.
     A list of converters are provided with a function. The first function to
     match is used, any function that is None will be used.
@@ -37,6 +44,6 @@
 
     .. code-block:: python
 
-        AURION_REFERENTIAL: Switch(
+        Switch(
             [
               (
@@ -41,7 +48,7 @@
             [
               (
-                  lambda e: e.is_xxx,
-                  lambda p: "wave_code" in p,
+                  lambda record: record.is_xxx,
+                  lambda message_value: "wave_code" in message_value,
                   Model("__wave__", {}),
               ),
               (None, None, Model("__event__", {})),
@@ -53,9 +60,9 @@
         self,
         converters: list[
             tuple[
-                Callable[[models.Model], bool],
-                Callable[[Any], bool],
-                Converter,
+                Callable[[models.BaseModel], bool] | None,
+                Callable[[Any], bool] | None,
+                Converter | SkipType,
             ]
         ],
         validator: Validator | None = None,
@@ -59,7 +66,7 @@
             ]
         ],
         validator: Validator | None = None,
-        validation: str = VALIDATION_SKIP,
+        validation: Validation = Validation.SKIP,
         context: ContextBuilder | None = None,
     ):
         """
@@ -74,9 +81,7 @@
         self.validator = validator
         self.validation = validation
 
-    def odoo_to_message(
-        self, instance: models.Model, ctx: Mapping | None = None
-    ) -> Any:
+    def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any:
         for out_cond, _in_cond, converter in self._converters:
             if out_cond is None or out_cond(instance):
                 if isinstance(converter, SkipType):
@@ -91,5 +96,5 @@
         odoo_env: api.Environment,
         phase: str,
         message_value: Any,
-        instance: models.Model,
+        instance: models.BaseModel,
         value_present: bool = True,
@@ -95,3 +100,3 @@
         value_present: bool = True,
-    ) -> dict:
+    ) -> dict | SkipType:
         for _out_cond, in_cond, converter in self._converters:
@@ -97,5 +102,7 @@
         for _out_cond, in_cond, converter in self._converters:
-            if in_cond is None or in_cond(message_value):
+            if not isinstance(converter, SkipType) and (
+                in_cond is None or in_cond(message_value)
+            ):
                 return converter.message_to_odoo(
                     odoo_env,
                     phase,
@@ -106,5 +113,6 @@
 
         return Skip
 
+    @property
     def is_instance_getter(self) -> bool:
         for _out_cond, _in_cond, converter in self._converters:
@@ -109,7 +117,7 @@
     def is_instance_getter(self) -> bool:
         for _out_cond, _in_cond, converter in self._converters:
-            if converter.is_instance_getter():
+            if not isinstance(converter, SkipType) and converter.is_instance_getter:
                 return True
 
         return False
 
@@ -112,6 +120,8 @@
                 return True
 
         return False
 
-    def get_instance(self, odoo_env, message_data):
+    def get_instance(
+        self, odoo_env: api.Environment, message_data
+    ) -> models.BaseModel | NewinstanceType | None:
         for _out_cond, in_cond, converter in self._converters:
@@ -117,5 +127,7 @@
         for _out_cond, in_cond, converter in self._converters:
-            if converter.is_instance_getter() and (
-                in_cond is None or in_cond(message_data)
+            if (
+                not isinstance(converter, SkipType)
+                and converter.is_instance_getter
+                and (in_cond is None or in_cond(message_data))
             ):
                 return converter.get_instance(odoo_env, message_data)
@@ -120,3 +132,4 @@
             ):
                 return converter.get_instance(odoo_env, message_data)
+        return super().get_instance(odoo_env, message_data)
 
@@ -122,4 +135,3 @@
 
-    @Converter.validator.setter
-    def validator(self, value: Validator | None) -> None:
+    def _set_validator(self, value: Validator | None) -> None:
         # also set validator on any converters in our switch, in case they care
@@ -125,3 +137,3 @@
         # also set validator on any converters in our switch, in case they care
-        Converter.validator.fset(self, value)
+        super()._set_validator(value)
         for _out_cond, _in_cond, converter in self._converters:
@@ -127,3 +139,4 @@
         for _out_cond, _in_cond, converter in self._converters:
-            converter.validator = value
+            if not isinstance(converter, SkipType):
+                converter.validator = value
 
@@ -129,4 +142,3 @@
 
-    @Converter.validation.setter
-    def validation(self, value: str) -> None:
+    def _set_validation(self, value: Validation) -> None:
         # also set validation on any converters in our switch
@@ -132,3 +144,3 @@
         # also set validation on any converters in our switch
-        Converter.validation.fset(self, value)
+        super()._set_validation(value)
         for _out_cond, _in_cond, converter in self._converters:
@@ -134,8 +146,9 @@
         for _out_cond, _in_cond, converter in self._converters:
-            converter.validation = value
+            if not isinstance(converter, SkipType):
+                converter.validation = value
 
     def get__type__(self) -> set[str]:
         types = set()
         for _out_cond, _in_cond, converter in self._converters:
             types.update(converter.get__type__())
         return types
@@ -136,6 +149,26 @@
 
     def get__type__(self) -> set[str]:
         types = set()
         for _out_cond, _in_cond, converter in self._converters:
             types.update(converter.get__type__())
         return types
+
+    @property
+    def possible_datatypes(self) -> set[str]:
+        result = super().possible_datatypes
+        for _out_cond, _in_cond, converter in self._converters:
+            result.update(converter.possible_datatypes)
+        return result
+
+    def odoo_datatype(self, instance: models.BaseModel) -> str | None:
+        for out_cond, _in_cond, converter in self._converters:
+            if out_cond is None or out_cond(instance):
+                return converter.odoo_datatype(instance)
+        return super().odoo_datatype(instance)
+
+    def post_hook(self, instance: models.BaseModel, message_data):
+        for _out_cond, in_cond, converter in self._converters:
+            if in_cond is None or in_cond(message_data):
+                if hasattr(converter, "post_hook"):
+                    converter.post_hook(instance, message_data)
+                return
diff --git a/tests/__init__.py b/tests/__init__.py
index eb1932fd59fdac405a8bafefd21995c78be45b23_dGVzdHMvX19pbml0X18ucHk=..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_dGVzdHMvX19pbml0X18ucHk= 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -6,4 +6,5 @@
     test_mail_template,
     test_relation,
     test_switch,
+    test_validate,
 )
diff --git a/tests/schemas/__init__.py b/tests/schemas/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_dGVzdHMvc2NoZW1hcy9fX2luaXRfXy5weQ==
--- /dev/null
+++ b/tests/schemas/__init__.py
@@ -0,0 +1,11 @@
+import json
+import pkgutil
+from typing import Any
+from collections.abc import Generator
+
+
+def get_schemas() -> Generator[Any]:
+    for file_prefix in ("product",):
+        data: bytes | None = pkgutil.get_data(__name__, f"{file_prefix}.schema.json")
+        if data:
+            yield json.loads(data)
diff --git a/tests/schemas/product.schema.json b/tests/schemas/product.schema.json
new file mode 100644
index 0000000000000000000000000000000000000000..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_dGVzdHMvc2NoZW1hcy9wcm9kdWN0LnNjaGVtYS5qc29u
--- /dev/null
+++ b/tests/schemas/product.schema.json
@@ -0,0 +1,49 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+
+  "$id": "https://example.com/product.schema.json",
+
+  "title": "Product",
+
+  "description": "A product from Acme's catalog",
+
+  "type": "object",
+
+  "properties": {
+    "productId": {
+      "description": "The unique identifier for a product",
+
+      "type": "integer"
+    },
+
+    "productName": {
+      "description": "Name of the product",
+
+      "type": "string"
+    },
+
+    "price": {
+      "description": "The price of the product",
+
+      "type": "number",
+
+      "exclusiveMinimum": 0
+    },
+
+    "tags": {
+      "description": "Tags for the product",
+
+      "type": "array",
+
+      "items": {
+        "type": "string"
+      },
+
+      "minItems": 1,
+
+      "uniqueItems": true
+    }
+  },
+
+  "required": ["productId", "productName", "price"]
+}
diff --git a/tests/schemas_dir/product.schema.json b/tests/schemas_dir/product.schema.json
new file mode 100644
index 0000000000000000000000000000000000000000..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_dGVzdHMvc2NoZW1hc19kaXIvcHJvZHVjdC5zY2hlbWEuanNvbg==
--- /dev/null
+++ b/tests/schemas_dir/product.schema.json
@@ -0,0 +1,49 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+
+  "$id": "https://example.com/product.schema.json",
+
+  "title": "Product",
+
+  "description": "A product from Acme's catalog",
+
+  "type": "object",
+
+  "properties": {
+    "productId": {
+      "description": "The unique identifier for a product",
+
+      "type": "integer"
+    },
+
+    "productName": {
+      "description": "Name of the product",
+
+      "type": "string"
+    },
+
+    "price": {
+      "description": "The price of the product",
+
+      "type": "number",
+
+      "exclusiveMinimum": 0
+    },
+
+    "tags": {
+      "description": "Tags for the product",
+
+      "type": "array",
+
+      "items": {
+        "type": "string"
+      },
+
+      "minItems": 1,
+
+      "uniqueItems": true
+    }
+  },
+
+  "required": ["productId", "productName", "price"]
+}
diff --git a/tests/test_base.py b/tests/test_base.py
index eb1932fd59fdac405a8bafefd21995c78be45b23_dGVzdHMvdGVzdF9iYXNlLnB5..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_dGVzdHMvdGVzdF9iYXNlLnB5 100644
--- a/tests/test_base.py
+++ b/tests/test_base.py
@@ -1,7 +1,7 @@
 ##############################################################################
 #
 #    Converter Odoo module
-#    Copyright © 2020 XCG Consulting <https://xcg-consulting.fr>
+#    Copyright © 2020, 2024 XCG Consulting <https://xcg-consulting.fr>
 #
 #    This program is free software: you can redistribute it and/or modify
 #    it under the terms of the GNU Affero General Public License as
@@ -17,4 +17,6 @@
 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
 #
 ##############################################################################
+from odoo import tests  # type: ignore[import-untyped]
+from odoo.exceptions import UserError  # type: ignore[import-untyped]
 
@@ -20,6 +22,5 @@
 
-from odoo import tests
-from odoo.addons.converter import (
+from .. import (
     Constant,
     Field,
     Model,
@@ -27,7 +28,6 @@
     message_to_odoo,
     relation,
 )
-from odoo.exceptions import UserError
 
 
 class Test(tests.TransactionCase):
@@ -43,8 +43,8 @@
         self.assertEqual("a", converter.odoo_to_message(self.env["res.users"]))
 
     def test_unset_field(self):
-        # this tests when a relationnal substitution
-        # is substitued on a record that has not
+        # this tests when a relational substitution
+        # is substituted on a record that has not
         # the relation set
 
         self.assertTrue(self.active_user.create_uid)
@@ -60,7 +60,6 @@
             Field("name"),
         )
         converter = Model(
-            None,
             {
                 "user_creator_name": rel,
             },
@@ -64,6 +63,7 @@
             {
                 "user_creator_name": rel,
             },
+            __type__="res.users",
         )
         with self.assertRaises(UserError) as e:
             converter.odoo_to_message(self.active_user)
@@ -84,7 +84,6 @@
 
     def test_convert(self):
         converter = Model(
-            None,
             {
                 "active": Field("active"),
                 "ref": Xref("base"),
diff --git a/tests/test_converters.py b/tests/test_converters.py
index eb1932fd59fdac405a8bafefd21995c78be45b23_dGVzdHMvdGVzdF9jb252ZXJ0ZXJzLnB5..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_dGVzdHMvdGVzdF9jb252ZXJ0ZXJzLnB5 100644
--- a/tests/test_converters.py
+++ b/tests/test_converters.py
@@ -18,7 +18,7 @@
 #
 ##############################################################################
 
-from odoo import tests
+from odoo import tests  # type: ignore[import-untyped]
 
 from ..base import Skip
 from ..field import Field
@@ -99,5 +99,4 @@
         """
         return Model(
             # TODO add a schema to test validation
-            "test-type",
             {
@@ -103,6 +102,6 @@
             {
-                "id": Xref(),
+                "id": Xref(include_module_name=True),
                 "country_code": RelationToOne(
                     "country_id", None, KeyField("code", "res.country")
                 ),
                 "name": Field("name"),
@@ -105,6 +104,8 @@
                 "country_code": RelationToOne(
                     "country_id", None, KeyField("code", "res.country")
                 ),
                 "name": Field("name"),
-                "partner_id": RelationToOne("partner_id", "res.partner", Xref()),
+                "partner_id": RelationToOne(
+                    "partner_id", "res.partner", Xref(include_module_name=True)
+                ),
             },
@@ -110,2 +111,3 @@
             },
+            __type__="test-type",
         )
diff --git a/tests/test_field.py b/tests/test_field.py
index eb1932fd59fdac405a8bafefd21995c78be45b23_dGVzdHMvdGVzdF9maWVsZC5weQ==..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_dGVzdHMvdGVzdF9maWVsZC5weQ== 100644
--- a/tests/test_field.py
+++ b/tests/test_field.py
@@ -20,8 +20,9 @@
 
 from typing import Any
 
-from odoo import tests
-from odoo.addons.converter import Field, TranslatedSelection
+from odoo import tests  # type: ignore[import-untyped]
+
+from .. import Field, TranslatedSelection
 
 
 class Test(tests.TransactionCase):
diff --git a/tests/test_ir_model.py b/tests/test_ir_model.py
index eb1932fd59fdac405a8bafefd21995c78be45b23_dGVzdHMvdGVzdF9pcl9tb2RlbC5weQ==..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_dGVzdHMvdGVzdF9pcl9tb2RlbC5weQ== 100644
--- a/tests/test_ir_model.py
+++ b/tests/test_ir_model.py
@@ -18,7 +18,7 @@
 #
 ##############################################################################
 
-from odoo.tests import TransactionCase, tagged
+from odoo.tests import TransactionCase, tagged  # type: ignore[import-untyped]
 
 
 @tagged("post_install", "-at_install")
diff --git a/tests/test_mail_template.py b/tests/test_mail_template.py
index eb1932fd59fdac405a8bafefd21995c78be45b23_dGVzdHMvdGVzdF9tYWlsX3RlbXBsYXRlLnB5..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_dGVzdHMvdGVzdF9tYWlsX3RlbXBsYXRlLnB5 100644
--- a/tests/test_mail_template.py
+++ b/tests/test_mail_template.py
@@ -1,7 +1,7 @@
 ##############################################################################
 #
 #    Converter Odoo module
-#    Copyright © 2023 XCG Consulting <https://xcg-consulting.fr>
+#    Copyright © 2023, 2024 XCG Consulting <https://xcg-consulting.fr>
 #
 #    This program is free software: you can redistribute it and/or modify
 #    it under the terms of the GNU Affero General Public License as
@@ -18,7 +18,8 @@
 #
 ##############################################################################
 
-from odoo import tests
-from odoo.addons.converter import MailTemplate
+from odoo import tests  # type: ignore[import-untyped]
+
+from ..mail_template import MailTemplate
 
 
@@ -23,5 +24,6 @@
 
 
+@tests.tagged("-standard", "odoo_addons_mail")
 class Test(tests.TransactionCase):
     """Test converter that wraps ``mail.template::_render_template``."""
 
diff --git a/tests/test_relation.py b/tests/test_relation.py
index eb1932fd59fdac405a8bafefd21995c78be45b23_dGVzdHMvdGVzdF9yZWxhdGlvbi5weQ==..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_dGVzdHMvdGVzdF9yZWxhdGlvbi5weQ== 100644
--- a/tests/test_relation.py
+++ b/tests/test_relation.py
@@ -1,7 +1,7 @@
 ##############################################################################
 #
 #    Converter Odoo module
-#    Copyright © 2021 XCG Consulting <https://xcg-consulting.fr>
+#    Copyright © 2021, 2024, 2025 XCG Consulting <https://xcg-consulting.fr>
 #
 #    This program is free software: you can redistribute it and/or modify
 #    it under the terms of the GNU Affero General Public License as
@@ -19,8 +19,9 @@
 ##############################################################################
 from typing import Any
 
-from odoo import tests
-from odoo.addons.converter import (
+from odoo import tests  # type: ignore[import-untyped]
+
+from .. import (
     Field,
     Model,
     RelationToMany,
@@ -35,7 +36,9 @@
     @classmethod
     def setUpClass(cls) -> None:
         super().setUpClass()
-        cls.converter1 = RelationToOne("company_id", "res.company", Xref(None))
+        cls.converter1 = RelationToOne(
+            "company_id", "res.company", Xref(None, prefix="main_")
+        )
         cls.converter2 = RelationToOne("action_id", "res.company", Xref(None))
         cls.converter3 = RelationToOne(
             "action_id", "res.company", Xref(None), send_empty=False
@@ -51,5 +54,5 @@
 
     def test_many2one_from_odoo(self):
         message = self.converter1.odoo_to_message(self.user_admin)
-        self.assertEqual(message, "base.main_company")
+        self.assertEqual(message, "company")
         message = self.converter1.odoo_to_message(self.user_root)
@@ -55,5 +58,5 @@
         message = self.converter1.odoo_to_message(self.user_root)
-        self.assertEqual(message, "base.main_company")
+        self.assertEqual(message, "company")
 
     def test_many2one_skip_from_odoo(self):
         message = self.converter4.odoo_to_message(self.user_admin)
@@ -57,7 +60,7 @@
 
     def test_many2one_skip_from_odoo(self):
         message = self.converter4.odoo_to_message(self.user_admin)
-        self.assertEqual(message, "base.main_company")
+        self.assertEqual(message, "main_company")
 
     def test_empty_many2one_from_odoo(self):
         message = self.converter2.odoo_to_message(self.user_root)
@@ -74,9 +77,8 @@
 
         # This converter wraps a user and adds info from its related partner.
         converter = Model(
-            None,
             {
                 "partner": RelationToOne(
                     "partner_id",
                     "res.partner",
                     Model(
@@ -78,11 +80,10 @@
             {
                 "partner": RelationToOne(
                     "partner_id",
                     "res.partner",
                     Model(
-                        "",
                         {
                             "color": Field("color"),
                             "name": Field("name"),
                             "xref": Xref("test"),
                         },
@@ -84,8 +85,9 @@
                         {
                             "color": Field("color"),
                             "name": Field("name"),
                             "xref": Xref("test"),
                         },
+                        __type__="partner",
                     ),
                 ),
                 "xref": Xref("base"),
@@ -96,5 +98,5 @@
         old_partner = user.partner_id
 
         # Run our message reception.
-        message = {
+        message: dict[str, Any] = {
             "partner": {
@@ -100,4 +102,5 @@
             "partner": {
+                "__type__": "partner",
                 "color": 2,
                 "name": "TEST",
                 "xref": "new_partner_converter",
@@ -106,7 +109,7 @@
         }
         message_to_odoo(self.env, message, self.env["res.users"], converter)
 
-        # Ensure a new partner got created and that is has an xref (post hook).
+        # Ensure a new partner got created and that it has an xref (post hook).
         new_partner = self.env.ref("test.new_partner_converter")
         self.assertEqual(user.partner_id, new_partner)
         self.assertNotEqual(new_partner, old_partner)
@@ -126,9 +129,8 @@
 
         # This converter wraps a user and adds info from its related partner.
         converter = Model(
-            None,
             {
                 "users": RelationToMany(
                     "user_ids",
                     "res.users",
                     Model(
@@ -130,10 +132,9 @@
             {
                 "users": RelationToMany(
                     "user_ids",
                     "res.users",
                     Model(
-                        "user",
                         {
                             "email": Field("email"),
                             "xref": Xref("base"),
                         },
@@ -136,7 +137,8 @@
                         {
                             "email": Field("email"),
                             "xref": Xref("base"),
                         },
+                        __type__="user",
                     ),
                 ),
                 "xref": Xref("base"),
@@ -165,3 +167,46 @@
         # Check the partner's users
         self.assertTrue(partner.user_ids)
         self.assertEqual(len(partner.user_ids), 2)
+
+    def test_many2many_to_odoo_no___type__(self):
+        """Ensure multiple sub-objects linked from the main one gets updated
+        when Odoo receives a message.
+        """
+
+        # This converter wraps a user and adds info from its related partner.
+        converter = Model(
+            {
+                "users": RelationToMany(
+                    "user_ids",
+                    "res.users",
+                    Model(
+                        {
+                            "email": Field("email"),
+                            "xref": Xref("base"),
+                        },
+                    ),
+                ),
+                "xref": Xref("base"),
+            },
+        )
+
+        partner = self.env.ref("base.main_partner")
+        self.assertFalse(partner.user_ids)
+
+        # Run our message reception.
+        message: dict[str, Any] = {
+            "users": [
+                {
+                    "xref": "user_admin",
+                },
+                {
+                    "xref": "user_demo",
+                },
+            ],
+            "xref": "main_partner",
+        }
+        message_to_odoo(self.env, message, self.env["res.partner"], converter)
+
+        # Check the partner's users
+        self.assertTrue(partner.user_ids)
+        self.assertEqual(len(partner.user_ids), 2)
diff --git a/tests/test_switch.py b/tests/test_switch.py
index eb1932fd59fdac405a8bafefd21995c78be45b23_dGVzdHMvdGVzdF9zd2l0Y2gucHk=..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_dGVzdHMvdGVzdF9zd2l0Y2gucHk= 100644
--- a/tests/test_switch.py
+++ b/tests/test_switch.py
@@ -18,7 +18,7 @@
 #
 ##############################################################################
 
-from odoo import tests
+from odoo import tests  # type: ignore[import-untyped]
 
 from .. import Field, Model, Switch
 
@@ -38,5 +38,4 @@
                     lambda e: e.active,
                     falser,
                     Model(
-                        "__activebanks__",
                         {"name": Field("name"), "active": Field("active")},
@@ -42,7 +41,8 @@
                         {"name": Field("name"), "active": Field("active")},
+                        __type__="__activebanks__",
                     ),
                 ),
                 (
                     None,
                     falser,
                     Model(
@@ -43,8 +43,7 @@
                     ),
                 ),
                 (
                     None,
                     falser,
                     Model(
-                        "__inactivebanks__",
                         {"name": Field("name"), "active": Field("active")},
@@ -50,4 +49,5 @@
                         {"name": Field("name"), "active": Field("active")},
+                        __type__="__inactivebanks__",
                     ),
                 ),
             ]
diff --git a/tests/test_validate.py b/tests/test_validate.py
new file mode 100644
index 0000000000000000000000000000000000000000..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_dGVzdHMvdGVzdF92YWxpZGF0ZS5weQ==
--- /dev/null
+++ b/tests/test_validate.py
@@ -0,0 +1,64 @@
+##############################################################################
+#
+#    Converter Odoo module
+#    Copyright © 2024 XCG Consulting <https://xcg-consulting.fr>
+#
+#    This program is free software: you can redistribute it and/or modify
+#    it under the terms of the GNU Affero General Public License as
+#    published by the Free Software Foundation, either version 3 of the
+#    License, or (at your option) any later version.
+#
+#    This program is distributed in the hope that it will be useful,
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#    GNU Affero General Public License for more details.
+#
+#    You should have received a copy of the GNU Affero General Public License
+#    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+##############################################################################
+import json
+
+from odoo import tests  # type: ignore[import-untyped]
+
+from ..validate import Validator
+
+
+class TestValidate(tests.TransactionCase):
+    def test_validate(self):
+        validator = Validator(
+            "odoo.addons.converter.tests.schemas", "https://example.com/{}.schema.json"
+        )
+        validator.initialize()
+        validator.validate(
+            "product",
+            json.loads("""{
+  "productId": 1,
+  "productName": "An ice sculpture",
+  "price": 12.5,
+  "tags": [
+    "cold",
+    "ice"
+  ]
+}"""),
+        )
+
+    def test_validate_dir(self):
+        validator = Validator(
+            "odoo.addons.converter.tests",
+            "https://example.com/{}.schema.json",
+            "schemas_dir",
+        )
+        validator.initialize()
+        validator.validate(
+            "product",
+            json.loads("""{
+  "productId": 1,
+  "productName": "An ice sculpture",
+  "price": 12.5,
+  "tags": [
+    "cold",
+    "ice"
+  ]
+}"""),
+        )
diff --git a/validate.py b/validate.py
index eb1932fd59fdac405a8bafefd21995c78be45b23_dmFsaWRhdGUucHk=..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_dmFsaWRhdGUucHk= 100644
--- a/validate.py
+++ b/validate.py
@@ -1,7 +1,7 @@
 ##############################################################################
 #
 #    Converter Odoo module
-#    Copyright © 2020 XCG Consulting <https://xcg-consulting.fr>
+#    Copyright © 2020, 2024 XCG Consulting <https://xcg-consulting.fr>
 #
 #    This program is free software: you can redistribute it and/or modify
 #    it under the terms of the GNU Affero General Public License as
@@ -17,5 +17,4 @@
 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
 #
 ##############################################################################
-
 import json
@@ -21,2 +20,3 @@
 import json
+import logging
 import os
@@ -22,2 +22,6 @@
 import os
+from collections.abc import Callable
+from enum import Enum
+from importlib import import_module
+from typing import Any
 
@@ -23,4 +27,7 @@
 
-import fastjsonschema
-import odoo.addons
+import fastjsonschema  # type: ignore[import-untyped]
+from odoo.exceptions import UserError  # type: ignore[import-untyped]
+
+_logger = logging.getLogger(__name__)
+
 
@@ -26,10 +33,13 @@
 
-VALIDATION_SKIP = "skip"
-VALIDATION_SOFT = "soft"
-VALIDATION_STRICT = "strict"
+class Validation(str, Enum):
+    """Type of validation"""
+
+    SKIP = "skip"
+    SOFT = "soft"
+    STRICT = "strict"
 
 
 class NotInitialized(Exception):
     pass
 
 
@@ -30,9 +40,16 @@
 
 
 class NotInitialized(Exception):
     pass
 
 
+def _add_schema(schemas, schema):
+    if "$id" in schema:
+        schemas[schema["$id"]] = schema
+    else:
+        _logger.warning("Schema without $id (schema ignored)")
+
+
 class Validator:
     def __init__(
         self,
@@ -36,6 +53,5 @@
 class Validator:
     def __init__(
         self,
-        repository_module_name: str,
-        repository: str,
+        package_name: str,
         default_url_pattern: str,
@@ -41,2 +57,3 @@
         default_url_pattern: str,
+        directory: str | None = None,
     ):
@@ -42,5 +59,10 @@
     ):
-        self.repository_module_name = repository_module_name
-        self.repository = repository
+        """
+        :param package_name: Package where the schema can be found
+        :param default_url_pattern: pattern for url ({} will be replaced by $id)
+        :param directory: directory to search for json, not used if a get_schema is
+        provided in the package.
+        """
+        self.package_name = package_name
         # exemple "https://annonces-legales.fr/xbus/schemas/v1/{}.schema.json"
         self.default_url_pattern = default_url_pattern
@@ -45,5 +67,5 @@
         # exemple "https://annonces-legales.fr/xbus/schemas/v1/{}.schema.json"
         self.default_url_pattern = default_url_pattern
-        self.validators = {}
+        self.validators: dict[str, Callable] = {}
         self.initialized = False
         self.encoding = "UTF-8"
@@ -48,4 +70,5 @@
         self.initialized = False
         self.encoding = "UTF-8"
+        self.directory = directory
 
     def initialize(self) -> None:
@@ -50,22 +73,30 @@
 
     def initialize(self) -> None:
-        repo_module_basepath = os.path.dirname(
-            getattr(odoo.addons, self.repository_module_name).__file__
-        )
-
-        # Read local schema definitions.
-        schemas = {}
-        schema_search_path = os.path.abspath(
-            os.path.join(repo_module_basepath, self.repository)
-        )
-        for root, _dirs, files in os.walk(schema_search_path):
-            for fname in files:
-                fpath = os.path.join(root, fname)
-                if fpath.endswith((".json",)):
-                    with open(fpath, encoding=self.encoding) as schema_fd:
-                        schema = json.load(schema_fd)
-                        if "$id" in schema:
-                            schemas[schema["$id"]] = schema
+        if self.initialized:
+            return
+        schemas: dict[str, Any] = {}
+        module = import_module(self.package_name)
+        if hasattr(module, "get_schemas"):
+            for schema in module.get_schemas():
+                _add_schema(schemas, schema)
+        else:
+            if module.__file__ is None:
+                # XXX maybe not the best type of error
+                raise UserError("Module %s has no file", self.package_name)
+            # Fallback on searching schema json files
+            schema_search_path = os.path.dirname(module.__file__)
+            schema_search_path = os.path.abspath(
+                os.path.join(schema_search_path, self.directory)
+                if self.directory is not None
+                else schema_search_path
+            )
+            for root, _dirs, files in os.walk(schema_search_path):
+                for fname in files:
+                    fpath = os.path.join(root, fname)
+                    if fpath.endswith((".json",)):
+                        with open(fpath, encoding=self.encoding) as schema_fd:
+                            schema = json.load(schema_fd)
+                            _add_schema(schemas, schema)
 
         # Prepare validators for each schema. We add an HTTPS handler that
         # points back to our schema definition cache built above.
@@ -77,7 +108,7 @@
             )
         self.initialized = True
 
-    def validate(self, schema_id, payload) -> None:
+    def validate(self, schema_id: str, payload) -> None:
         if not self.initialized:
             raise NotInitialized("please call the initialize() method")
 
diff --git a/xref.py b/xref.py
index eb1932fd59fdac405a8bafefd21995c78be45b23_eHJlZi5weQ==..81195e4078e5aa2a32ffc2a6f2c707b998f4eabb_eHJlZi5weQ== 100644
--- a/xref.py
+++ b/xref.py
@@ -1,7 +1,7 @@
 ##############################################################################
 #
 #    Converter Odoo module
-#    Copyright © 2020 XCG Consulting <https://xcg-consulting.fr>
+#    Copyright © 2020, 2025 XCG Consulting <https://xcg-consulting.fr>
 #
 #    This program is free software: you can redistribute it and/or modify
 #    it under the terms of the GNU Affero General Public License as
@@ -17,4 +17,8 @@
 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
 #
 ##############################################################################
+import logging
+import os
+import uuid
+from typing import Any, Final
 
@@ -20,3 +24,3 @@
 
-from typing import Any
+from odoo import _, api, models  # type: ignore[import-untyped]
 
@@ -22,3 +26,11 @@
 
-from odoo import models
+from .base import (
+    Context,
+    ContextBuilder,
+    Converter,
+    NewinstanceType,
+    PostHookConverter,
+    build_context,
+)
+from .models.ir_model_data import _XREF_IMD_MODULE
 
@@ -24,6 +36,5 @@
 
-from .base import Converter, NewinstanceType
-from .models.ir_model_data import _XREF_IMD_MODULE
+_logger = logging.getLogger(__name__)
 
 
 # TODO dans quel cas ça ne pourrait pas être un instance getter???
@@ -27,8 +38,8 @@
 
 
 # TODO dans quel cas ça ne pourrait pas être un instance getter???
-class Xref(Converter):
+class Xref(PostHookConverter):
     """This converter represents an external reference, using the standard xmlid with a
     custom module name.
     """
 
@@ -31,8 +42,18 @@
     """This converter represents an external reference, using the standard xmlid with a
     custom module name.
     """
 
-    def __init__(self, module: str = _XREF_IMD_MODULE, is_instance_getter: bool = True):
+    def __init__(
+        self,
+        module: str | None = _XREF_IMD_MODULE,
+        is_instance_getter: bool = True,
+        include_module_name: bool = False,
+        prefix: str = "",
+    ):
+        """
+        :param prefix: prefix to use in ir.model.data, nor sent nor received.
+          Used to prevent duplication if received id is too simple.
+        """
         super().__init__()
         self._module = module
         self._is_instance_getter = is_instance_getter
@@ -36,4 +57,6 @@
         super().__init__()
         self._module = module
         self._is_instance_getter = is_instance_getter
+        self._include_module_name: Final[bool] = include_module_name
+        self._prefix: Final[str] = prefix
 
@@ -39,4 +62,4 @@
 
-    def odoo_to_message(self, instance: models.Model, ctx: dict | None = None) -> Any:
+    def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any:
         if not instance:
             return ""
@@ -41,5 +64,5 @@
         if not instance:
             return ""
-        return instance.env["ir.model.data"].object_to_xmlid(
-            instance, module=self._module
+        module, name = instance.env["ir.model.data"].object_to_module_and_name(
+            instance, self._module, self._prefix
         )
@@ -45,3 +68,8 @@
         )
+        if self._prefix is not None:
+            name = name[len(self._prefix) :]
+        if self._include_module_name:
+            return f"{module}.{name}"
+        return name
 
     def get_instance(
@@ -46,8 +74,19 @@
 
     def get_instance(
-        self, odoo_env, message_data
-    ) -> None | models.Model | NewinstanceType:
-        return odoo_env.ref(
-            ".".join([self._module, message_data]), raise_if_not_found=False
+        self, odoo_env: api.Environment, message_data
+    ) -> models.BaseModel | NewinstanceType | None:
+        if self._is_instance_getter:
+            module, name = self._module_name(message_data)
+            return odoo_env.ref(
+                f"{module}.{name}",
+                raise_if_not_found=False,
+            )
+        return None
+
+    def post_hook(self, instance: models.BaseModel, message_data):
+        # add xmlid to the newly created object
+        module, name = self._module_name(message_data)
+        instance.env["ir.model.data"].set_xmlid(
+            instance, name, module=module, only_when_missing=True
         )
 
@@ -52,8 +91,68 @@
         )
 
-    def post_hook(self, instance: models.Model, message_data):
-        # add xmlid to the newly created object
-        instance.env["ir.model.data"].set_xmlid(
-            instance, message_data, module=self._module, only_when_missing=True
+    def _module_name(self, value: str) -> tuple[str, str]:
+        """Return module and name depending on options"""
+        module = "" if self._module is None else self._module
+        name = value
+        if self._include_module_name:
+            module, name = value.split(".", 1)
+            assert module == self._module
+        if self._prefix is not None:
+            name = self._prefix + name
+        return module, name
+
+    @property
+    def is_instance_getter(self) -> bool:
+        return self._is_instance_getter
+
+
+class JsonLD_ID(Xref):
+    """This converter represents a JsonLD ID , an url made of
+    a base part defined as ir.config_parameter, an optional breadcrumb
+    and a unique id part using the standard xmlid.
+    """
+
+    def __init__(
+        self,
+        breadcrumb: str | Converter,
+        module: str | None = _XREF_IMD_MODULE,
+        is_instance_getter: bool = True,
+        unique_id_field: str | None = None,
+        context: ContextBuilder | None = None,
+        has_base_url: bool = True,
+    ):
+        """
+        :param breadcrumb: Part of the url describing the entity,
+        must match the syntax expected by os.path, ie absolute path
+        begins with a slash. With absolute path the base part is
+        ignored. Can also be a converter, if so, the result of the
+        combined converters must be a string.
+        """
+        super().__init__(
+            module=module,
+            is_instance_getter=is_instance_getter,
+        )
+        self.converter: Converter | None = None
+        self._breadcrumb = breadcrumb if isinstance(breadcrumb, str) else None
+        if isinstance(breadcrumb, Converter):
+            self.converter = breadcrumb
+        self._unique_id_field = unique_id_field
+        self._context = context
+        self._has_base_url = has_base_url
+
+    def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any:
+        if not instance:
+            return ""
+
+        imds = (
+            instance.env["ir.model.data"]
+            .sudo()
+            .search(
+                [
+                    ("module", "=", self._module),
+                    ("model", "=", instance._name),
+                    ("res_id", "=", instance.id),
+                ]
+            )
         )
 
@@ -58,4 +157,35 @@
         )
 
-    def is_instance_getter(self) -> bool:
-        return self._is_instance_getter
+        ctx = build_context(instance, ctx, self._context)
+        jsonld_id_base_url = (
+            instance.env["ir.config_parameter"]
+            .sudo()
+            .get_param("sync.jsonld_id_base_url")
+        )
+        if self._has_base_url and not jsonld_id_base_url:
+            _logger.error(
+                _("Missing config parameter: 'sync.jsonld_id_base_url' is not defined")
+            )
+            return ""
+
+        if self.converter is not None:
+            self._breadcrumb = self.converter.odoo_to_message(instance, ctx)
+
+        # xref does not exist or does not match the jsonld expected format, create it
+        schema_base = os.path.join(
+            jsonld_id_base_url if self._has_base_url else "",
+            self._breadcrumb if self._breadcrumb is not None else "",
+        )
+        if not imds or all(not imd.name.startswith(schema_base) for imd in imds):
+            if self._unique_id_field is not None:
+                name = getattr(instance, self._unique_id_field)
+            else:
+                name = uuid.uuid4().hex
+
+            xref = os.path.join(schema_base, name)
+            instance.env["ir.model.data"].set_xmlid(instance, xref, module=self._module)
+        else:
+            for imd in imds:
+                if imd.name.startswith(schema_base):
+                    xref = imd.name
+        return xref