diff --git a/__init__.py b/__init__.py
index edc971a2dc195ec9b65a7ed93de4df4c1ce14e79_X19pbml0X18ucHk=..85125162a750c2a37d21b92c0d17f73993cdac38_X19pbml0X18ucHk= 100644
--- a/__init__.py
+++ b/__init__.py
@@ -42,11 +42,5 @@
 from .model import Model
 from .relation import RelationToMany, RelationToManyMap, RelationToOne, relation
 from .switch import Switch
-from .validate import (
-    VALIDATION_SKIP,
-    VALIDATION_SOFT,
-    VALIDATION_STRICT,
-    NotInitialized,
-    Validator,
-)
+from .validate import NotInitialized, Validation, Validator
 from .xref import Xref
diff --git a/__manifest__.py b/__manifest__.py
index edc971a2dc195ec9b65a7ed93de4df4c1ce14e79_X19tYW5pZmVzdF9fLnB5..85125162a750c2a37d21b92c0d17f73993cdac38_X19tYW5pZmVzdF9fLnB5 100644
--- a/__manifest__.py
+++ b/__manifest__.py
@@ -17,7 +17,6 @@
 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
 #
 ##############################################################################
-
 {
     "name": "Converter",
     "license": "AGPL-3",
@@ -26,8 +25,7 @@
     "category": "Hidden",
     "author": "XCG Consulting",
     "website": "https://orbeet.io/",
-    "depends": ["base", "mail"],
-    "data": [],
+    "depends": ["mail"],
     "installable": True,
     "external_dependencies": {"python": ["fastjsonschema"]},
 }
diff --git a/base.py b/base.py
index edc971a2dc195ec9b65a7ed93de4df4c1ce14e79_YmFzZS5weQ==..85125162a750c2a37d21b92c0d17f73993cdac38_YmFzZS5weQ== 100644
--- a/base.py
+++ b/base.py
@@ -36,6 +36,7 @@
 
 import inspect
 import logging
+from abc import ABCMeta, abstractmethod
 from collections.abc import Mapping
 from typing import Any, Callable, Optional
 
@@ -39,7 +40,7 @@
 from collections.abc import Mapping
 from typing import Any, Callable, Optional
 
-from odoo import api, models
+from odoo import api, models  # type: ignore[import-untyped]
 
 from .exception import InternalError
 from .validate import Validator
@@ -60,7 +61,8 @@
 
 Newinstance = NewinstanceType()
 
-ContextBuilder = Callable[[models.Model, Optional[Mapping]], Optional[Mapping]]
+Context = Optional[Mapping]
+ContextBuilder = Callable[[models.BaseModel, Context], Context]
 
 PHASE_PRECREATE = "precreate"
 PHASE_POSTCREATE = "postcreate"
@@ -72,5 +74,5 @@
 
 def build_context(
     instance: Optional[models.Model],
-    ctx: Optional[Mapping],
+    ctx: Context,
     extend: Optional[ContextBuilder],
@@ -76,5 +78,5 @@
     extend: Optional[ContextBuilder],
-) -> Optional[dict]:
+) -> Context:
     if instance is None:
         return ctx
     if extend:
@@ -82,7 +84,9 @@
             ctx = {}
         else:
             ctx = dict(ctx)
-        ctx.update(extend(instance))
+        extended = extend(instance, None)
+        if extended is not None:
+            ctx.update(extended)
     return ctx
 
 
@@ -91,9 +95,7 @@
     It does not actually convert anything.
     """
 
-    def odoo_to_message(
-        self, instance: models.Model, ctx: Optional[Mapping] = None
-    ) -> Any:
+    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,7 +109,7 @@
         odoo_env: api.Environment,
         phase: str,
         message_value: Any,
-        instance: models.Model,
+        instance: models.BaseModel,
         value_present: bool = True,
     ) -> dict:
         """From a message, returns a dict.
@@ -123,7 +125,7 @@
         """
         return {}
 
-    @classmethod
-    def is_instance_getter(cls) -> bool:
+    @property
+    def is_instance_getter(self) -> bool:
         return False
 
@@ -128,5 +130,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 Exception("Not an instance getter")
+
     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 +145,7 @@
     @property
     def validator(self) -> Optional[Validator]:
         """A validator to use for validation of created messages"""
-        return self._validator
+        return self._get_validator()
 
     @validator.setter
     def validator(self, value: Optional[Validator]) -> None:
@@ -140,6 +149,12 @@
 
     @validator.setter
     def validator(self, value: Optional[Validator]) -> 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:
@@ -152,7 +167,7 @@
 
     @property
     def validation(self) -> str:
-        return self._validation
+        return self._get_validation()
 
     @validation.setter
     def validation(self, value: str) -> None:
@@ -156,8 +171,14 @@
 
     @validation.setter
     def validation(self, value: str) -> None:
+        self._set_validation(value)
+
+    def _get_validation(self) -> str:
+        return self._validation
+
+    def _set_validation(self, value: str) -> None:
         """Define if validation should be done"""
         assert value is not None
         self._validation = value
 
 
@@ -159,10 +180,16 @@
         """Define if validation should be done"""
         assert value is not None
         self._validation = value
 
 
+class PostHookConverter(Converter, metaclass=ABCMeta):
+    @abstractmethod
+    def post_hook(self, instance: models.BaseModel, message_data):
+        """Post hook"""
+
+
 class Readonly(Converter):
     def __init__(self, conv):
         super().__init__()
         self.conv = conv
 
@@ -164,12 +191,10 @@
 class Readonly(Converter):
     def __init__(self, conv):
         super().__init__()
         self.conv = conv
 
-    def odoo_to_message(
-        self, instance: models.Model, ctx: Optional[dict] = None
-    ) -> Any:
+    def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any:
         return self.conv.odoo_to_message(instance, ctx)
 
 
 class Computed(Converter):
@@ -172,11 +197,11 @@
         return self.conv.odoo_to_message(instance, ctx)
 
 
 class Computed(Converter):
-    def __init__(self, from_odoo: Callable[[models.Model, Optional[Mapping]], Any]):
+    def __init__(self, from_odoo: Callable[[models.BaseModel, Context], Any]):
         self.from_odoo = from_odoo
 
         sig = inspect.signature(from_odoo)
         self.from_odoo_arg_count = len(sig.parameters)
         if self.from_odoo_arg_count not in (1, 2):
             raise ValueError(
@@ -177,10 +202,10 @@
         self.from_odoo = from_odoo
 
         sig = inspect.signature(from_odoo)
         self.from_odoo_arg_count = len(sig.parameters)
         if self.from_odoo_arg_count not in (1, 2):
             raise ValueError(
-                "Computed 'from_odoo' callback must have 1 or 2 args: got %s"
-                % self.from_odoo_arg_count
+                "Computed 'from_odoo' callback must have 1 or 2 args: got "
+                f"{self.from_odoo_arg_count}"
             )
 
@@ -185,6 +210,4 @@
             )
 
-    def odoo_to_message(
-        self, instance: models.Model, ctx: Optional[Mapping] = None
-    ) -> Any:
+    def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any:
         if self.from_odoo_arg_count == 1:
@@ -190,5 +213,5 @@
         if self.from_odoo_arg_count == 1:
-            return self.from_odoo(instance)
+            return self.from_odoo(instance, None)
         return self.from_odoo(instance, ctx)
 
 
@@ -196,9 +219,7 @@
     def __init__(self, value):
         self.value = value
 
-    def odoo_to_message(
-        self, instance: models.Model, ctx: Optional[Mapping] = None
-    ) -> Any:
+    def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any:
         return self.value
 
 
@@ -208,7 +229,7 @@
     model_name: str,
     converter: Converter,
     operation: Optional[str] = None,
-) -> models.Model:
+) -> models.BaseModel:
     """
 
     :param odoo_env: an Odoo environment
@@ -220,6 +241,7 @@
        :py:meth:odoo.addons.Converter.get_instance
     :return:
     """
+    instance: NewinstanceType | models.BaseModel
     if operation == OPERATION_CREATION:
         instance = Newinstance
     else:
diff --git a/doc/autotodo.py b/doc/autotodo.py
index edc971a2dc195ec9b65a7ed93de4df4c1ce14e79_ZG9jL2F1dG90b2RvLnB5..85125162a750c2a37d21b92c0d17f73993cdac38_ZG9jL2F1dG90b2RvLnB5 100755
--- a/doc/autotodo.py
+++ b/doc/autotodo.py
@@ -2,7 +2,7 @@
 ##############################################################################
 #
 #    OpenERP, Open Source Management Solution
-#    Copyright © 2014, 2018, 2022, 2023 XCG Consulting
+#    Copyright © 2014, 2018, 2022-2024 XCG Consulting
 #
 #    This program is free software: you can redistribute it and/or modify
 #    it under the terms of the GNU Affero General Public License as
@@ -22,7 +22,7 @@
 import os
 import os.path
 import sys
-from collections.abc import Mapping
+from collections.abc import MutableMapping
 
 
 def main():
@@ -31,13 +31,13 @@
         sys.exit(1)
 
     folder = sys.argv[1]
-    exts = sys.argv[2].split(",")
-    tags = sys.argv[3].split(",")
-    todolist = {tag: [] for tag in tags}
-    path_file_length: Mapping[str, int] = {}
+    exts: list[str] = sys.argv[2].split(",")
+    tags: list[str] = sys.argv[3].split(",")
+    todolist: dict[str, list[tuple[str, int, str]]] = {tag: [] for tag in tags}
+    path_file_length: MutableMapping[str, int] = {}
 
     for root, _dirs, files in os.walk(folder):
         scan_folder((exts, tags, todolist, path_file_length), root, files)
     create_autotodo(folder, todolist, path_file_length)
 
 
@@ -38,10 +38,10 @@
 
     for root, _dirs, files in os.walk(folder):
         scan_folder((exts, tags, todolist, path_file_length), root, files)
     create_autotodo(folder, todolist, path_file_length)
 
 
-def write_info(f, infos, folder, path_file_length: Mapping[str, int]):
+def write_info(f, infos, folder, path_file_length: MutableMapping[str, int]):
     # Check sphinx version for lineno-start support
 
     import sphinx
@@ -55,5 +55,5 @@
         path = i[0]
         line = i[1]
         lines = (line - 3, min(line + 4, path_file_length[path]))
-        class_name = ":class:`%s`" % os.path.basename(os.path.splitext(path)[0])
+        class_name = f":class:`{os.path.basename(os.path.splitext(path)[0])}`"
         f.write(
@@ -59,6 +59,6 @@
         f.write(
-            "%s\n"
-            "%s\n\n"
-            "Line %s\n"
-            "\t.. literalinclude:: %s\n"
+            "{}\n"
+            "{}\n\n"
+            "Line {}\n"
+            "\t.. literalinclude:: {}\n"
             "\t\t:language: python\n"
@@ -64,7 +64,6 @@
             "\t\t:language: python\n"
-            "\t\t:lines: %s-%s\n"
-            "\t\t:emphasize-lines: %s\n"
-            % (
+            "\t\t:lines: {}-{}\n"
+            "\t\t:emphasize-lines: {}\n".format(
                 class_name,
                 "-" * len(class_name),
                 line,
@@ -75,7 +74,7 @@
             )
         )
         if lineno_start:
-            f.write("\t\t:lineno-start: %s\n" % lines[0])
+            f.write(f"\t\t:lineno-start: {lines[0]}\n")
         f.write("\n")
 
 
@@ -79,6 +78,6 @@
         f.write("\n")
 
 
-def create_autotodo(folder, todolist, path_file_length: Mapping[str, int]):
+def create_autotodo(folder, todolist, path_file_length: MutableMapping[str, int]):
     with open("autotodo", "w+") as f:
         for tag, info in list(todolist.items()):
@@ -83,6 +82,6 @@
     with open("autotodo", "w+") as f:
         for tag, info in list(todolist.items()):
-            f.write("%s\n%s\n\n" % (tag, "=" * len(tag)))
+            f.write("{}\n{}\n\n".format(tag, "=" * len(tag)))
             write_info(f, info, folder, path_file_length)
 
 
@@ -86,7 +85,16 @@
             write_info(f, info, folder, path_file_length)
 
 
-def scan_folder(data_tuple, dirname, names):
+def scan_folder(
+    data_tuple: tuple[
+        list[str],
+        list[str],
+        dict[str, list[tuple[str, int, str]]],
+        MutableMapping[str, int],
+    ],
+    dirname: str,
+    names: list[str],
+):
     (exts, tags, res, path_file_length) = data_tuple
     for name in names:
         (root, ext) = os.path.splitext(name)
@@ -99,6 +107,8 @@
                     res[tag].extend(info)
 
 
-def scan_file(filename, tags) -> tuple[dict[str, list[tuple[str, int, str]]], int]:
+def scan_file(
+    filename: str, tags: list[str]
+) -> tuple[dict[str, list[tuple[str, int, str]]], int]:
     res: dict[str, list[tuple[str, int, str]]] = {tag: [] for tag in tags}
     line_num: int = 0
@@ -103,6 +113,6 @@
     res: dict[str, list[tuple[str, int, str]]] = {tag: [] for tag in tags}
     line_num: int = 0
-    with open(filename, "r") as f:
+    with open(filename) as f:
         for line_num, line in enumerate(f):
             for tag in tags:
                 if tag in line:
diff --git a/doc/conf.py b/doc/conf.py
index edc971a2dc195ec9b65a7ed93de4df4c1ce14e79_ZG9jL2NvbmYucHk=..85125162a750c2a37d21b92c0d17f73993cdac38_ZG9jL2NvbmYucHk= 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -14,5 +14,5 @@
 from importlib.metadata import PackageNotFoundError
 from importlib.metadata import version as import_version
 
-from odoo_scripts.config import Configuration
+from odoo_scripts.config import Configuration  # type: ignore[import-untyped]
 
@@ -18,5 +18,5 @@
 
-import odoo
+import odoo  # 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
@@ -111,8 +111,6 @@
 
 # -- Options for LaTeX output ---------------------------------------------
 
-latex_elements = {}
-
 # Grouping the document tree into LaTeX files. List of tuples
 # (source start file, target name, title,
 #  author, documentclass [howto, manual, or own class]).
@@ -163,5 +161,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
@@ -167,5 +165,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):
@@ -169,9 +172,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:
@@ -187,7 +190,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
@@ -192,8 +196,9 @@
 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 edc971a2dc195ec9b65a7ed93de4df4c1ce14e79_ZmllbGQucHk=..85125162a750c2a37d21b92c0d17f73993cdac38_ZmllbGQucHk= 100644
--- a/field.py
+++ b/field.py
@@ -19,7 +19,7 @@
 ##############################################################################
 
 import datetime
-from typing import Any, Callable, Optional
+from typing import Any, Callable, Literal, Optional
 
 import pytz
 
@@ -23,5 +23,5 @@
 
 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
 
 
 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,9 +87,7 @@
 
         return self._blank_value
 
-    def odoo_to_message(
-        self, instance: models.Model, ctx: Optional[dict] = 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
@@ -122,7 +120,7 @@
         odoo_env: api.Environment,
         phase: str,
         message_value: Any,
-        instance: models.Model,
+        instance: models.BaseModel,
         value_present: bool = True,
     ) -> dict:
         if phase == PHASE_POSTCREATE:
@@ -163,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)
@@ -175,9 +173,7 @@
                 (b, a) for a, b in description_selection
             )
 
-    def odoo_to_message(
-        self, instance: models.Model, ctx: Optional[dict] = 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)
@@ -189,7 +185,7 @@
         odoo_env: api.Environment,
         phase: str,
         message_value: Any,
-        instance: models.Model,
+        instance: models.BaseModel,
         value_present: bool = True,
     ) -> dict:
         message = super().message_to_odoo(
diff --git a/keyfield.py b/keyfield.py
index edc971a2dc195ec9b65a7ed93de4df4c1ce14e79_a2V5ZmllbGQucHk=..85125162a750c2a37d21b92c0d17f73993cdac38_a2V5ZmllbGQucHk= 100644
--- a/keyfield.py
+++ b/keyfield.py
@@ -18,5 +18,5 @@
 #
 ##############################################################################
 
-from typing import Any, Optional
+from typing import Any
 
@@ -22,3 +22,3 @@
 
-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,8 +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: Optional[dict] = None
-    ) -> Any:
+    def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any:
         return getattr(instance, self.field_name)
 
@@ -45,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
         )
@@ -52,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 edc971a2dc195ec9b65a7ed93de4df4c1ce14e79_bGlzdC5weQ==..85125162a750c2a37d21b92c0d17f73993cdac38_bGlzdC5weQ== 100644
--- a/list.py
+++ b/list.py
@@ -20,5 +20,5 @@
 
 from typing import Any, Optional
 
-from odoo import models
+from odoo import models  # type: ignore[import-untyped]
 
@@ -24,5 +24,5 @@
 
-from .base import ContextBuilder, Converter, Skip, build_context
+from .base import Context, ContextBuilder, Converter, Skip, build_context
 
 
 class List(Converter):
@@ -37,9 +37,7 @@
         self._converters = converters
         self.context = context
 
-    def odoo_to_message(
-        self, instance: models.Model, ctx: Optional[dict] = None
-    ) -> Any:
+    def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any:
         ctx = build_context(instance, ctx, self.context)
 
         message_data = []
@@ -56,7 +54,7 @@
         odoo_env,
         phase: str,
         message_value,
-        instance: models.Model,
+        instance: models.BaseModel,
         value_present: bool = True,
     ) -> dict:
         return {}
diff --git a/mail_template.py b/mail_template.py
index edc971a2dc195ec9b65a7ed93de4df4c1ce14e79_bWFpbF90ZW1wbGF0ZS5weQ==..85125162a750c2a37d21b92c0d17f73993cdac38_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,5 +17,4 @@
 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
 #
 ##############################################################################
-
 import ast
@@ -21,3 +20,3 @@
 import ast
-from typing import Any, Optional
+from typing import Any
 
@@ -23,3 +22,3 @@
 
-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,6 +26,6 @@
 
 
-class MailTemplate(base.Converter):
+class MailTemplate(Converter):
     """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"]``.
@@ -36,7 +35,7 @@
         self.template = template
         self.post_eval = post_eval
 
-    def odoo_to_message(self, records: models.Model, ctx: Optional[dict] = 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 edc971a2dc195ec9b65a7ed93de4df4c1ce14e79_bW9kZWwucHk=..85125162a750c2a37d21b92c0d17f73993cdac38_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,9 +17,8 @@
 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
 #
 ##############################################################################
-
 import logging
 from collections import OrderedDict
 from collections.abc import Iterable, Mapping
 from typing import Any, Optional
 
@@ -21,8 +20,10 @@
 import logging
 from collections import OrderedDict
 from collections.abc import Iterable, Mapping
 from typing import Any, Optional
 
-from odoo import api, models
+import fastjsonschema  # type: ignore[import-untyped]
+
+from odoo import api, models  # type: ignore[import-untyped]
 
 from .base import (
@@ -27,6 +28,7 @@
 
 from .base import (
+    Context,
     ContextBuilder,
     Converter,
     Newinstance,
     NewinstanceType,
@@ -29,7 +31,8 @@
     ContextBuilder,
     Converter,
     Newinstance,
     NewinstanceType,
+    PostHookConverter,
     Skip,
     build_context,
 )
@@ -33,8 +36,8 @@
     Skip,
     build_context,
 )
-from .validate import VALIDATION_SKIP, VALIDATION_STRICT, Validator
+from .validate import NotInitialized, Validation, Validator
 
 _logger = logging.getLogger(__name__)
 
 
@@ -37,9 +40,9 @@
 
 _logger = logging.getLogger(__name__)
 
 
-class Model(Converter):
+class Model(PostHookConverter):
     """A converter that takes a dict of key, used when a message has values"""
 
     def __init__(
         self,
@@ -42,11 +45,11 @@
     """A converter that takes a dict of key, used when a message has values"""
 
     def __init__(
         self,
-        __type__: str,
+        __type__: str | None,
         converters: Mapping[str, Converter],
         json_schema: Optional[str] = None,
         # The validator is usually not given at this point but is common
         # throughout a project. That’s why it is a property
         validator: Optional[Validator] = None,
         merge_with: Optional[Iterable[Converter]] = None,
@@ -47,10 +50,10 @@
         converters: Mapping[str, Converter],
         json_schema: Optional[str] = None,
         # The validator is usually not given at this point but is common
         # throughout a project. That’s why it is a property
         validator: Optional[Validator] = None,
         merge_with: Optional[Iterable[Converter]] = None,
-        validation: str = VALIDATION_SKIP,
+        validation: Validation = Validation.SKIP,
         context: Optional[ContextBuilder] = None,
     ):
         super().__init__()
@@ -54,5 +57,5 @@
         context: Optional[ContextBuilder] = None,
     ):
         super().__init__()
-        self._type: str = __type__
+        self._type: str | None = __type__
         self._converters: Mapping[str, Converter] = converters
@@ -58,3 +61,3 @@
         self._converters: Mapping[str, Converter] = converters
-        self._post_hooks_converters_key: list[str] = []
+        self._post_hooks_converters: dict[str, PostHookConverter] = {}
         self._jsonschema: Optional[str] = json_schema
@@ -60,5 +63,5 @@
         self._jsonschema: Optional[str] = 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: Optional[Iterable[Converter]] = merge_with
         self.context: Optional[ContextBuilder] = context
@@ -66,5 +69,5 @@
         self.validation = validation
 
         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
@@ -70,4 +73,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
 
@@ -73,7 +76,5 @@
 
-    def odoo_to_message(
-        self, instance: models.Model, ctx: Optional[dict] = 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 +78,7 @@
         ctx = build_context(instance, ctx, self.context)
 
         message_data = {}
-        if self._type:
+        if self._type is not None:
             message_data["__type__"] = self._type
 
         for key in self._converters:
@@ -92,13 +93,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 Exception("Strict validation without validator")
 
         return message_data
 
@@ -107,8 +111,8 @@
         odoo_env: api.Environment,
         phase: str,
         message_value: Any,
-        instance: models.Model,
+        instance: models.BaseModel,
         value_present: bool = True,
     ) -> dict:
         values = OrderedDict()
 
@@ -111,8 +115,8 @@
         value_present: bool = True,
     ) -> dict:
         values = OrderedDict()
 
-        if self._type and message_value["__type__"] != self._type:
+        if self._type is not None and message_value["__type__"] != self._type:
             raise Exception(
                 "Expected __type__ {}, found {}".format(
                     self._type, message_value["__type__"]
@@ -140,8 +144,9 @@
 
         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
@@ -143,9 +148,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(
@@ -156,6 +161,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:
@@ -161,8 +166,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]:
@@ -163,7 +168,7 @@
         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}
diff --git a/models/ir_model_data.py b/models/ir_model_data.py
index edc971a2dc195ec9b65a7ed93de4df4c1ce14e79_bW9kZWxzL2lyX21vZGVsX2RhdGEucHk=..85125162a750c2a37d21b92c0d17f73993cdac38_bW9kZWxzL2lyX21vZGVsX2RhdGEucHk= 100644
--- a/models/ir_model_data.py
+++ b/models/ir_model_data.py
@@ -20,9 +20,9 @@
 
 import uuid
 
-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.
 _XREF_IMD_MODULE = "__converter__"
 
 
@@ -24,9 +24,9 @@
 
 # Xrefs are stored within "ir.model.data" with this module name.
 _XREF_IMD_MODULE = "__converter__"
 
 
-class IrModelData(models.Model):
+class IrModelData(models.BaseModel):
     """Add xref tools.
     All done with the super-admin user to bypass security rules.
 
@@ -37,5 +37,5 @@
     _inherit = "ir.model.data"
 
     @api.model
-    def generate_name(self):
+    def generate_name(self) -> str:
         """Generate an xref for odoo record;
@@ -41,7 +41,7 @@
         """Generate an xref for odoo record;
-        It return a UUID from a string of 32 hex digit
+        :return: a UUID from a string of 32 hex digit
         """
 
         return uuid.uuid4().hex
 
     @api.model
@@ -43,10 +43,12 @@
         """
 
         return uuid.uuid4().hex
 
     @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
+    ) -> 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 +51,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: Tuple[str, str]
         :return: tuple module and name
         """
         record_set.ensure_one()
@@ -76,8 +76,10 @@
         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
+    ) -> 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,9 +82,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
         """
         return "{0[0]}.{0[1]}".format(
             self.object_to_module_and_name(record_set, module)
@@ -91,6 +91,6 @@
     @api.model
     def set_xmlid(
         self,
-        record_set: models.Model,
+        record_set: models.BaseModel,
         name: str,
         module: str = _XREF_IMD_MODULE,
@@ -95,6 +95,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 edc971a2dc195ec9b65a7ed93de4df4c1ce14e79_cHlwcm9qZWN0LnRvbWw=..85125162a750c2a37d21b92c0d17f73993cdac38_cHlwcm9qZWN0LnRvbWw= 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -18,6 +18,7 @@
 [project.optional-dependencies]
 doc = ["sphinx", "sphinx-odoo-autodoc"]
 test = []
+typing = ["types-pytz"]
 
 [project.urls]
 repository = "https://orus.io/xcg/odoo-modules/converter"
diff --git a/relation.py b/relation.py
index edc971a2dc195ec9b65a7ed93de4df4c1ce14e79_cmVsYXRpb24ucHk=..85125162a750c2a37d21b92c0d17f73993cdac38_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,7 +17,6 @@
 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
 #
 ##############################################################################
-
 import logging
 from typing import Any, Callable, Optional
 
@@ -21,5 +20,5 @@
 import logging
 from typing import Any, Callable, Optional
 
-from odoo import api, models
+from odoo import api, models  # type: ignore[import-untyped]
 
@@ -25,5 +24,12 @@
 
-from .base import ContextBuilder, Converter, NewinstanceType, Skip, build_context
+from .base import (
+    Context,
+    ContextBuilder,
+    Converter,
+    NewinstanceType,
+    Skip,
+    build_context,
+)
 from .field import Field
 
 _logger = logging.getLogger(__name__)
@@ -33,7 +39,7 @@
     def __init__(
         self,
         field_name: str,
-        model_name: str,
+        model_name: str | None,
         converter: Converter,
         send_empty: bool = True,
         context: Optional[ContextBuilder] = None,
@@ -44,9 +50,7 @@
         self._send_empty = send_empty
         self.context = context
 
-    def odoo_to_message(
-        self, instance: models.Model, ctx: Optional[dict] = 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)
@@ -62,7 +66,7 @@
         odoo_env: api.Environment,
         phase: str,
         message_value: Any,
-        instance: models.Model,
+        instance: models.BaseModel,
         value_present: bool = True,
     ) -> dict:
         if not value_present:
@@ -83,8 +87,8 @@
         field_name: str,
         model_name: Optional[str],
         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: Optional[ContextBuilder] = None,
         limit: Optional[Any] = None,
     ):
@@ -99,9 +103,7 @@
         self.context = context
         self.limit = limit
 
-    def odoo_to_message(
-        self, instance: models.Model, ctx: Optional[dict] = 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)
         if value is Skip:
@@ -124,7 +126,7 @@
         odoo_env: api.Environment,
         phase: str,
         message_value: Any,
-        instance: models.Model,
+        instance: models.BaseModel,
         value_present: bool = True,
     ) -> dict:
         # if not present or value is None, do not update the values.
@@ -154,7 +156,7 @@
         model_name: Optional[str],
         key_converter: Converter,
         value_converter: Converter,
-        filtered: None | str | Callable[[models.Model], bool] = None,
+        filtered: None | str | Callable[[models.BaseModel], bool] = None,
         context: Optional[ContextBuilder] = None,
     ):
         """
@@ -167,9 +169,7 @@
         self.filtered = filtered
         self.context = context
 
-    def odoo_to_message(
-        self, instance: models.Model, ctx: Optional[dict] = 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)
         if value is Skip:
@@ -193,7 +193,7 @@
         odoo_env: api.Environment,
         phase: str,
         message_value: Any,
-        instance: models.Model,
+        instance: models.BaseModel,
         value_present: bool = True,
     ) -> dict:
         # if not present or value is None, do not update the values.
diff --git a/switch.py b/switch.py
index edc971a2dc195ec9b65a7ed93de4df4c1ce14e79_c3dpdGNoLnB5..85125162a750c2a37d21b92c0d17f73993cdac38_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 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 Mapping
-from typing import Any, Callable, Optional
+from collections.abc import Callable
+from typing import Any, Optional
 
@@ -23,3 +22,3 @@
 
-from odoo import api, models
+from odoo import api, models  # type: ignore[import-untyped]
 
@@ -25,6 +24,6 @@
 
-from .base import ContextBuilder, Converter, Skip, SkipType
-from .validate import VALIDATION_SKIP, Validator
+from .base import Context, ContextBuilder, Converter, NewinstanceType, Skip, SkipType
+from .validate import Validation, Validator
 
 
 class Switch(Converter):
@@ -53,9 +52,9 @@
         self,
         converters: list[
             tuple[
-                Callable[[models.Model], bool],
+                Callable[[models.BaseModel], bool] | None,
                 Callable[[Any], bool],
                 Converter,
             ]
         ],
         validator: Optional[Validator] = None,
@@ -57,9 +56,9 @@
                 Callable[[Any], bool],
                 Converter,
             ]
         ],
         validator: Optional[Validator] = None,
-        validation: str = VALIDATION_SKIP,
+        validation: str = Validation.SKIP,
         context: Optional[ContextBuilder] = None,
     ):
         """
@@ -74,9 +73,7 @@
         self.validator = validator
         self.validation = validation
 
-    def odoo_to_message(
-        self, instance: models.Model, ctx: Optional[Mapping] = 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,7 +88,7 @@
         odoo_env: api.Environment,
         phase: str,
         message_value: Any,
-        instance: models.Model,
+        instance: models.BaseModel,
         value_present: bool = True,
     ) -> dict:
         for _out_cond, in_cond, converter in self._converters:
@@ -104,5 +101,5 @@
                     value_present=value_present,
                 )
 
-        return Skip
+        return {}
 
@@ -108,3 +105,4 @@
 
+    @property
     def is_instance_getter(self) -> bool:
         for _out_cond, _in_cond, converter in self._converters:
@@ -109,7 +107,7 @@
     def is_instance_getter(self) -> bool:
         for _out_cond, _in_cond, converter in self._converters:
-            if converter.is_instance_getter():
+            if converter.is_instance_getter:
                 return True
 
         return False
 
@@ -112,6 +110,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 +117,5 @@
         for _out_cond, in_cond, converter in self._converters:
-            if converter.is_instance_getter() and (
+            if converter.is_instance_getter and (
                 in_cond is None or in_cond(message_data)
             ):
                 return converter.get_instance(odoo_env, message_data)
@@ -119,4 +119,5 @@
                 in_cond is None or in_cond(message_data)
             ):
                 return converter.get_instance(odoo_env, message_data)
+        return super().get_instance(odoo_env, message_data)
 
@@ -122,4 +123,3 @@
 
-    @Converter.validator.setter
-    def validator(self, value: Optional[Validator]) -> None:
+    def _set_validator(self, value: Validator | None) -> None:
         # also set validator on any converters in our switch, in case they care
@@ -125,5 +125,5 @@
         # 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:
             converter.validator = value
 
@@ -127,6 +127,5 @@
         for _out_cond, _in_cond, converter in self._converters:
             converter.validator = value
 
-    @Converter.validation.setter
-    def validation(self, value: str) -> None:
+    def _set_validation(self, value: str) -> None:
         # also set validation on any converters in our switch
@@ -132,5 +131,5 @@
         # 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:
             converter.validation = value
 
diff --git a/tests/test_base.py b/tests/test_base.py
index edc971a2dc195ec9b65a7ed93de4df4c1ce14e79_dGVzdHMvdGVzdF9iYXNlLnB5..85125162a750c2a37d21b92c0d17f73993cdac38_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,7 +22,5 @@
 
-from odoo import tests
-
-from odoo.addons.converter import Constant, Field, Model, Xref, message_to_odoo
+from .. import Constant, Field, Model, Xref, message_to_odoo, relation
 
 
 class Test(tests.TransactionCase):
@@ -34,6 +34,46 @@
         converter = Constant("a")
         self.assertEqual("a", converter.odoo_to_message(self.env["res.users"]))
 
+    def test_unset_field(self):
+        # this tests when a relational substitution
+        # is substituted on a record that has not
+        # the relation set
+
+        self.assertTrue(self.active_user.create_uid)
+        # set its create_uid to False
+        self.active_user.write(
+            {
+                "create_uid": False,
+            }
+        )
+
+        rel = relation(
+            "create_uid",
+            Field("name"),
+        )
+        converter = Model(
+            "res.users",
+            {
+                "user_creator_name": rel,
+            },
+        )
+        with self.assertRaises(UserError) as e:
+            converter.odoo_to_message(self.active_user)
+
+        self.assertTrue(
+            str(e.exception).startswith(
+                "Got unexpected errors while parsing substitutions:"
+            ),
+            "UserError does not start as expected",
+        )
+
+        self.assertTrue(
+            str(e.exception).endswith(
+                "KeyError: None\nKey: user_creator_name",
+            ),
+            "UserError does not end as expected",
+        )
+
     def test_convert(self):
         converter = Model(
             None,
diff --git a/tests/test_converters.py b/tests/test_converters.py
index edc971a2dc195ec9b65a7ed93de4df4c1ce14e79_dGVzdHMvdGVzdF9jb252ZXJ0ZXJzLnB5..85125162a750c2a37d21b92c0d17f73993cdac38_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
diff --git a/tests/test_field.py b/tests/test_field.py
index edc971a2dc195ec9b65a7ed93de4df4c1ce14e79_dGVzdHMvdGVzdF9maWVsZC5weQ==..85125162a750c2a37d21b92c0d17f73993cdac38_dGVzdHMvdGVzdF9maWVsZC5weQ== 100644
--- a/tests/test_field.py
+++ b/tests/test_field.py
@@ -20,5 +20,5 @@
 
 from typing import Any
 
-from odoo import tests
+from odoo import tests  # type: ignore[import-untyped]
 
@@ -24,5 +24,5 @@
 
-from odoo.addons.converter import Field, TranslatedSelection
+from .. import Field, TranslatedSelection
 
 
 class Test(tests.TransactionCase):
diff --git a/tests/test_ir_model.py b/tests/test_ir_model.py
index edc971a2dc195ec9b65a7ed93de4df4c1ce14e79_dGVzdHMvdGVzdF9pcl9tb2RlbC5weQ==..85125162a750c2a37d21b92c0d17f73993cdac38_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 edc971a2dc195ec9b65a7ed93de4df4c1ce14e79_dGVzdHMvdGVzdF9tYWlsX3RlbXBsYXRlLnB5..85125162a750c2a37d21b92c0d17f73993cdac38_dGVzdHMvdGVzdF9tYWlsX3RlbXBsYXRlLnB5 100644
--- a/tests/test_mail_template.py
+++ b/tests/test_mail_template.py
@@ -18,5 +18,5 @@
 #
 ##############################################################################
 
-from odoo import tests
+from odoo import tests  # type: ignore[import-untyped]
 
@@ -22,5 +22,5 @@
 
-from odoo.addons.converter import MailTemplate
+from .. import MailTemplate
 
 
 class Test(tests.TransactionCase):
diff --git a/tests/test_relation.py b/tests/test_relation.py
index edc971a2dc195ec9b65a7ed93de4df4c1ce14e79_dGVzdHMvdGVzdF9yZWxhdGlvbi5weQ==..85125162a750c2a37d21b92c0d17f73993cdac38_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 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,5 +19,5 @@
 ##############################################################################
 from typing import Any
 
-from odoo import tests
+from odoo import tests  # type: ignore[import-untyped]
 
@@ -23,13 +23,5 @@
 
-from odoo.addons.converter import (
-    Field,
-    Model,
-    RelationToMany,
-    RelationToOne,
-    Skip,
-    Xref,
-    message_to_odoo,
-)
+from .. import Field, Model, RelationToMany, RelationToOne, Skip, Xref, message_to_odoo
 
 
 class Test(tests.TransactionCase):
@@ -81,7 +73,7 @@
                     "partner_id",
                     "res.partner",
                     Model(
-                        "",
+                        "partner",
                         {
                             "color": Field("color"),
                             "name": Field("name"),
@@ -97,5 +89,5 @@
         old_partner = user.partner_id
 
         # Run our message reception.
-        message = {
+        message: dict[str, Any] = {
             "partner": {
@@ -101,4 +93,5 @@
             "partner": {
+                "__type__": "partner",
                 "color": 2,
                 "name": "TEST",
                 "xref": "new_partner_converter",
@@ -107,7 +100,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)
diff --git a/tests/test_switch.py b/tests/test_switch.py
index edc971a2dc195ec9b65a7ed93de4df4c1ce14e79_dGVzdHMvdGVzdF9zd2l0Y2gucHk=..85125162a750c2a37d21b92c0d17f73993cdac38_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
 
diff --git a/validate.py b/validate.py
index edc971a2dc195ec9b65a7ed93de4df4c1ce14e79_dmFsaWRhdGUucHk=..85125162a750c2a37d21b92c0d17f73993cdac38_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,6 +17,5 @@
 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
 #
 ##############################################################################
-
 import json
 import os
@@ -21,3 +20,5 @@
 import json
 import os
+from enum import Enum
+from typing import Any, LiteralString
 
@@ -23,3 +24,5 @@
 
-import fastjsonschema
+import fastjsonschema  # type: ignore[import-untyped]
+
+import odoo.addons  # type: ignore[import-untyped]
 
@@ -25,3 +28,5 @@
 
-import odoo.addons
+
+class Validation(str, Enum):
+    """Type of validation"""
 
@@ -27,7 +32,7 @@
 
-VALIDATION_SKIP = "skip"
-VALIDATION_SOFT = "soft"
-VALIDATION_STRICT = "strict"
+    SKIP = "skip"
+    SOFT = "soft"
+    STRICT = "strict"
 
 
 class NotInitialized(Exception):
@@ -45,8 +50,8 @@
         self.repository = repository
         # exemple "https://annonces-legales.fr/xbus/schemas/v1/{}.schema.json"
         self.default_url_pattern = default_url_pattern
-        self.validators = {}
+        self.validators: dict[LiteralString, Any] = {}
         self.initialized = False
         self.encoding = "UTF-8"
 
     def initialize(self) -> None:
@@ -49,7 +54,8 @@
         self.initialized = False
         self.encoding = "UTF-8"
 
     def initialize(self) -> None:
+        # TODO Not working if module is installed compressed
         repo_module_basepath = os.path.dirname(
             getattr(odoo.addons, self.repository_module_name).__file__
         )
@@ -78,7 +84,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 edc971a2dc195ec9b65a7ed93de4df4c1ce14e79_eHJlZi5weQ==..85125162a750c2a37d21b92c0d17f73993cdac38_eHJlZi5weQ== 100644
--- a/xref.py
+++ b/xref.py
@@ -18,5 +18,5 @@
 #
 ##############################################################################
 
-from typing import Any, Optional
+from typing import Any
 
@@ -22,3 +22,3 @@
 
-from odoo import models
+from odoo import models  # type: ignore[import-untyped]
 
@@ -24,6 +24,6 @@
 
-from .base import Converter, NewinstanceType
+from .base import Context, NewinstanceType, PostHookConverter
 from .models.ir_model_data import _XREF_IMD_MODULE
 
 
 # TODO dans quel cas ça ne pourrait pas être un instance getter???
@@ -26,9 +26,9 @@
 from .models.ir_model_data import _XREF_IMD_MODULE
 
 
 # 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,9 +31,11 @@
     """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
+    ):
         super().__init__()
         self._module = module
         self._is_instance_getter = is_instance_getter
 
@@ -36,10 +38,8 @@
         super().__init__()
         self._module = module
         self._is_instance_getter = is_instance_getter
 
-    def odoo_to_message(
-        self, instance: models.Model, ctx: Optional[dict] = None
-    ) -> Any:
+    def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any:
         if not instance:
             return ""
         return instance.env["ir.model.data"].object_to_xmlid(
@@ -48,8 +48,11 @@
 
     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
-        )
+    ) -> None | models.BaseModel | NewinstanceType:
+        if self._is_instance_getter:
+            return odoo_env.ref(
+                ".".join(["" if self._module is None else self._module, message_data]),
+                raise_if_not_found=False,
+            )
+        return None
 
@@ -55,7 +58,7 @@
 
-    def post_hook(self, instance: models.Model, message_data):
+    def post_hook(self, instance: models.BaseModel, 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
         )
 
@@ -57,7 +60,8 @@
         # add xmlid to the newly created object
         instance.env["ir.model.data"].set_xmlid(
             instance, message_data, module=self._module, only_when_missing=True
         )
 
+    @property
     def is_instance_getter(self) -> bool:
         return self._is_instance_getter