Repository: yukinarit/pyserde

Issue:
Incorrect deserialization of dataclasses with similar fields
Here is an example where I have two very similar classes, Alpha and Bravo. Alpha has an additional optional field. I serialize Bravo, and when I deserialize I get back Alpha.

```python
from dataclasses import dataclass
from typing import Union, Optional

from serde import deserialize, serialize
from serde.json import from_json, to_json


@deserialize
@serialize
@dataclass
class Alpha:
    a: str
    b: Optional[str]

@deserialize
@serialize
@dataclass
class Bravo:
    a: str

@deserialize
@serialize
@dataclass
class Foo:
    d: Union[Alpha, Bravo]


def main():
    f = Foo(Bravo(a=1))

    # convert into json
    Bravo_json = to_json(f)
    print(f"Into Json: {Bravo_json}")

    # convert back
    print(f"From Json: {from_json(Foo, Bravo_json)}")


if __name__ == '__main__':
    main()
```

Output:
```
Into Json: {"d": {"a": 1}}
From Json: Foo(d=Alpha(a=1, b=None))
```

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/yukinarit/pyserde/pull/187?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yukinarit) Report
> Merging [#187](https://codecov.io/gh/yukinarit/pyserde/pull/187?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yukinarit) (3c2482a) into [master](https://codecov.io/gh/yukinarit/pyserde/commit/a96beebe17d42fd4d0f66abbfc1e33597783e4d0?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yukinarit) (a96beeb) will **increase** coverage by `0.44%`.
> The diff coverage is `97.77%`.

[![Impacted file tree graph](https://codecov.io/gh/yukinarit/pyserde/pull/187/graphs/tree.svg?width=650&height=150&src=pr&token=fF9S7dP4eH&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yukinarit)](https://codecov.io/gh/yukinarit/pyserde/pull/187?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yukinarit)

```diff
@@            Coverage Diff             @@
##           master     #187      +/-   ##
==========================================
+ Coverage   92.28%   92.73%   +0.44%     
==========================================
  Files          10       10              
  Lines        1154     1183      +29     
  Branches      254      256       +2     
==========================================
+ Hits         1065     1097      +32     
+ Misses         58       55       -3     
  Partials       31       31              
```


| [Impacted Files](https://codecov.io/gh/yukinarit/pyserde/pull/187?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yukinarit) | Coverage Δ | |
|---|---|---|
| [serde/core.py](https://codecov.io/gh/yukinarit/pyserde/pull/187/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yukinarit#diff-c2VyZGUvY29yZS5weQ==) | `90.94% <97.14%> (+2.22%)` | :arrow_up: |
| [serde/\_\_init\_\_.py](https://codecov.io/gh/yukinarit/pyserde/pull/187/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yukinarit#diff-c2VyZGUvX19pbml0X18ucHk=) | `83.33% <100.00%> (ø)` | |
| [serde/de.py](https://codecov.io/gh/yukinarit/pyserde/pull/187/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yukinarit#diff-c2VyZGUvZGUucHk=) | `97.51% <100.00%> (ø)` | |
| [serde/se.py](https://codecov.io/gh/yukinarit/pyserde/pull/187/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yukinarit#diff-c2VyZGUvc2UucHk=) | `96.12% <100.00%> (-0.02%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/yukinarit/pyserde/pull/187?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yukinarit).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yukinarit)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/yukinarit/pyserde/pull/187?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yukinarit). Last update [a96beeb...3c2482a](https://codecov.io/gh/yukinarit/pyserde/pull/187?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yukinarit). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yukinarit).


Patch:
diff --git a/README.md b/README.md
index bf44857..2dd5052 100644
--- a/README.md
+++ b/README.md
@@ -59,7 +59,9 @@ Foo(i=10, s='foo', f=100.0, b=True)
         - [`uuid.UUID`](https://docs.python.org/3/library/uuid.html)
         - [`datetime.date`](https://docs.python.org/3/library/datetime.html#date-objects), [`datetime.time`](https://docs.python.org/3/library/datetime.html#time-objects), [`datetime.datetime`](https://docs.python.org/3/library/datetime.html#datetime-objects)
         - [`ipaddress`](https://docs.python.org/3/library/ipaddress.html)
-- [decorators](docs/features/decorators.md)
+- [Attributes](docs/features/attributes.md)
+- [Decorators](docs/features/decorators.md)
+- [Union Representation](docs/features/union.md)
 - [Python 3.9 type hinting](docs/features/python3.9-type-hinting.md)
 - [Postponed evaluation of type annotation](docs/features/postponed-evaluation-of-type-annotation.md)
 - [Forward reference](docs/features/forward-reference.md)
diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md
index 2dd6630..532e555 100644
--- a/docs/SUMMARY.md
+++ b/docs/SUMMARY.md
@@ -7,6 +7,7 @@
 - [Features](features/summary.md)
     - [Attributes](features/attributes.md)
     - [Decorators](features/decorators.md)
+    - [Union Representation](features/union.md)
     - [Python 3.9 type hinting](features/python3.9-type-hinting.md)
     - [Postponed evaluation of type annotation](features/postponed-evaluation-of-type-annotation.md)
     - [Forward reference](features/forward-reference.md)
diff --git a/docs/features/union.md b/docs/features/union.md
new file mode 100644
index 0000000..4d58231
--- /dev/null
+++ b/docs/features/union.md
@@ -0,0 +1,61 @@
+# Union Representation
+
+`pyserde`>=0.7 offers attributes to control how Union is (de)serialized. This concept is the very same as the one in [serde-rs](https://serde.rs/enum-representations.html). Note these representations only apply to the dataclass, non dataclass objects are (de)serialized with `Untagged` always.
+
+## `Untagged`
+
+This is the default Union representation for pyserde<0.7. Given these dataclasses,
+
+```python
+@serde
+class Bar:
+    b: int
+
+@serde
+class Baz:
+    b: int
+
+@serde(tagging=Untagged)
+class Foo:
+    a: Union[Bar, Baz]
+```
+
+Note that `Bar` and `Baz` have the same field name and type. If you serialize `Foo(Baz(10))` into dict, you get `{"a": {"b": 10}}`. But if you deserialize `{"a": {"b": 10}}`, you get `Foo(Bar(10))` instead of `Foo(Baz(10))`. This means pyserde **can't correctly (de)serialize dataclasses of Union with `Untagged`**. This is why pyserde offers other kinds of union representation options.
+
+## `ExternalTagging`
+
+This is the default Union representation since 0.7. A class declaration with `ExternalTagging` looks like below. If you serialize `Foo(Baz(10))` into dict, you get `{"a": {"Baz": {"b": 10}}}` and you can deserialize it back to `Foo(Baz(10))`. 
+
+```
+@serde(tagging=ExternalTagging)
+class Foo:
+    a: Union[Bar, Baz]
+```
+
+> **NOTE:** Non dataclass objects are alreays (de)serialized with `Ungagged` regardless of `tagging` attribute because there is no information which can be used for tag. The drawback of `Untagged` is pyserde can't correctly deserialize certain types. For example, `Foo({1, 2, 3})` of below class is serialized into `{"a": [1, 2, 3]}`, but you get `Foo([1, 2, 3])` by deserializing.
+>
+> ```python
+> @serde(tagging=ExternalTagging)
+> class Foo:
+>    a: Union[List[int], Set[int]]
+> ```
+
+## `InternalTagging`
+
+A class declaration with `InternalTagging` looks like below. If you serialize `Foo(Baz(10))` into dict, you will get `{"a": {"type": "Baz", "b": 10}}` and you can deserialize it back to `Foo(Baz(10))`. `type` tag is encoded inside the `Baz`'s dictionary.
+
+```python
+@serde(tagging=InternalTagging("type"))
+class Foo:
+    a: Union[Bar, Baz]
+```
+
+## `AdjacentTagging`
+
+A class declaration with `AdjacentTagging` looks like below. If you serialize `Foo(Baz(10))` into dict, you will get `{"a": {"type": "Baz", "content": {"b": 10}}}` and you can deserialize it back to `Foo(Baz(10))`. `type` tag is encoded inside `Baz`'s dictionary and `Baz`s fields are encoded inside `content`.
+
+```python
+@serde(tagging=AdjacentTagging("type", "content"))
+class Foo:
+    a: Union[Bar, Baz]
+```
\ No newline at end of file
diff --git a/docs/introduction.md b/docs/introduction.md
index a4d6758..90fbb98 100644
--- a/docs/introduction.md
+++ b/docs/introduction.md
@@ -46,6 +46,9 @@ Foo(i=10, s='foo', f=100.0, b=True)
         - [`uuid.UUID`](https://docs.python.org/3/library/uuid.html)
         - [`datetime.date`](https://docs.python.org/3/library/datetime.html#date-objects) & [`datetime.datetime`](https://docs.python.org/3/library/datetime.html#datetime-objects)
         - [`ipaddress`](https://docs.python.org/3/library/ipaddress.html)
+- [Attributes](features/attributes.md)
+- [Decorators](features/decorators.md)
+- [Union Representation](features/union.md)
 - [Python 3.9 type hiting](features/python3.9-type-hinting.md)
 - [Postponed evaluation of type annotation](features/postponed-evaluation-of-type-annotation.md)
 - [Forward reference](features/forward-reference.md)
diff --git a/examples/runner.py b/examples/runner.py
index 8f5d64d..99eb173 100644
--- a/examples/runner.py
+++ b/examples/runner.py
@@ -18,6 +18,7 @@ import tomlfile
 import type_datetime
 import type_decimal
 import union
+import union_tagging
 import yamlfile
 
 PY36 = sys.version_info[:3] < (3, 7, 0)
diff --git a/examples/tomlfile.py b/examples/tomlfile.py
index 04074df..ec2a38a 100644
--- a/examples/tomlfile.py
+++ b/examples/tomlfile.py
@@ -9,7 +9,7 @@ Usage:
 """
 from typing import Dict, List, Optional, Union
 
-from serde import serde
+from serde import Untagged, serde
 from serde.toml import from_toml
 
 
@@ -32,7 +32,7 @@ class Package:
     editable: Optional[bool] = False
 
 
-@serde
+@serde(tagging=Untagged)
 class Pipfile:
     source: List[Source]
     requires: Optional[Requires]
diff --git a/examples/union.py b/examples/union.py
index 34c532d..e476cea 100644
--- a/examples/union.py
+++ b/examples/union.py
@@ -4,23 +4,34 @@ from serde import serde
 from serde.json import from_json, to_json
 
 
+@serde
+class Bar:
+    v: int
+
+
+@serde
+class Baz:
+    v: float
+
+
 @serde
 class Foo:
-    v: Union[int, str]
-    c: Union[Dict[str, int], List[int]]
+    a: Union[int, str]
+    b: Union[Dict[str, int], List[int]]
+    c: Union[Bar, Baz]
 
 
 def main():
-    f = Foo(10, [1, 2, 3])
+    f = Foo(10, [1, 2, 3], Bar(10))
     print(f"Into Json: {to_json(f)}")
 
-    s = '{"v": 10, "c": [1, 2, 3]}'
+    s = '{"a": 10, "b": [1, 2, 3], "c": {"Bar": {"v": 10}}}'
     print(f"From Json: {from_json(Foo, s)}")
 
-    f = Foo('foo', {'bar': 1, 'baz': 2})
+    f = Foo('foo', {'bar': 1, 'baz': 2}, Baz(100.0))
     print(f"Into Json: {to_json(f)}")
 
-    s = '{"v": "foo", "c": {"bar": 1, "baz": 2}}'
+    s = '{"a": "foo", "b": {"bar": 1, "baz": 2}, "c": {"Baz": {"v": 100.0}}}'
     print(f"From Json: {from_json(Foo, s)}")
 
 
diff --git a/examples/union_tagging.py b/examples/union_tagging.py
new file mode 100644
index 0000000..d8951b8
--- /dev/null
+++ b/examples/union_tagging.py
@@ -0,0 +1,78 @@
+from typing import Union
+
+from serde import AdjacentTagging, InternalTagging, Untagged, serde
+from serde.json import from_json, to_json
+
+
+@serde
+class Bar:
+    b: int
+
+
+@serde
+class Baz:
+    b: int
+
+
+def external_tagging():
+    @serde
+    class Foo:
+        a: Union[Bar, Baz]
+
+    f = Foo(Baz(10))
+    print(f"Into Json: {to_json(f)}")
+
+    s = '{"a": {"Baz": {"b": 10}}}'
+    print(f"From Json: {from_json(Foo, s)}")
+
+
+def internal_tagging():
+    @serde(tagging=InternalTagging("type"))
+    class Foo:
+        a: Union[Bar, Baz]
+
+    f = Foo(Baz(10))
+    print(f"Into Json: {to_json(f)}")
+
+    s = '{"a": {"type": "Baz", "b": 10}}'
+    print(f"From Json: {from_json(Foo, s)}")
+
+
+def adjacent_tagging():
+    @serde(tagging=AdjacentTagging(tag="type", content="content"))
+    class Foo:
+        a: Union[Bar, Baz]
+
+    f = Foo(Baz(10))
+    print(f"Into Json: {to_json(f)}")
+
+    s = '{"a": {"type": "Baz", "content": {"b": 10}}}'
+    print(f"From Json: {from_json(Foo, s)}")
+
+
+def untagged():
+    @serde(tagging=Untagged)
+    class Foo:
+        a: Union[Bar, Baz]
+
+    f = Foo(Baz(10))
+    print(f"Into Json: {to_json(f)}")
+
+    # Untagged cannot correctly deserialize unions with the same fields.
+    s = '{"a": {"b": 10}}'
+    print(f"From Json: {from_json(Foo, s)}")
+
+
+def main():
+    print('# external tagging (default)')
+    external_tagging()
+    print('# internal tagging')
+    internal_tagging()
+    print('# adjacent tagging')
+    adjacent_tagging()
+    print('# untagged')
+    untagged()
+
+
+if __name__ == '__main__':
+    main()
diff --git a/examples/yamlfile.py b/examples/yamlfile.py
index 9907ef2..8c8b87f 100644
--- a/examples/yamlfile.py
+++ b/examples/yamlfile.py
@@ -9,7 +9,7 @@ Usage:
 """
 from typing import Dict, List, Optional, Union
 
-from serde import serde
+from serde import Untagged, serde
 from serde.yaml import from_yaml
 
 
@@ -34,7 +34,7 @@ class Response:
     description: str
 
 
-@serde(rename_all='camelcase')
+@serde(rename_all='camelcase', tagging=Untagged)
 class Path:
     description: str
     operation_id: str
diff --git a/serde/__init__.py b/serde/__init__.py
index 12665a4..f642f70 100644
--- a/serde/__init__.py
+++ b/serde/__init__.py
@@ -45,7 +45,7 @@ import dataclasses
 import sys
 
 from .compat import SerdeError, SerdeSkip  # noqa
-from .core import field, init, logger  # noqa
+from .core import AdjacentTagging, ExternalTagging, InternalTagging, Untagged, field, init, logger  # noqa
 from .de import default_deserializer, deserialize, from_dict, from_tuple, is_deserializable  # noqa
 from .se import asdict, astuple, default_serializer, is_serializable, serialize, to_dict, to_tuple  # noqa
 
diff --git a/serde/core.py b/serde/core.py
index c8bc998..b61a838 100644
--- a/serde/core.py
+++ b/serde/core.py
@@ -4,6 +4,7 @@ pyserde core module.
 import dataclasses
 import datetime
 import decimal
+import enum
 import functools
 import ipaddress
 import logging
@@ -527,8 +528,57 @@ def union_func_name(prefix: str, union_args: List[Type]) -> str:
     return re.sub(r"[ ,\[\]]+", "_", f"{prefix}_{'_'.join([typename(e) for e in union_args])}")
 
 
-def filter_scope(scope: Dict[str, Any]) -> Iterator[str]:
-    for k, v in scope.items():
-        if v.__module__ == "typing":
-            continue
-        yield k
+@dataclass
+class Tagging:
+    """
+    Controls how union is (de)serialized. This is the same concept as in
+    https://serde.rs/enum-representations.html
+    """
+
+    class Kind(enum.Enum):
+        External = enum.auto()
+        Internal = enum.auto()
+        Adjacent = enum.auto()
+        Untagged = enum.auto()
+
+    tag: Optional[str] = None
+    content: Optional[str] = None
+    kind: Kind = Kind.External
+
+    def is_external(self):
+        return self.kind == self.Kind.External
+
+    def is_internal(self):
+        return self.kind == self.Kind.Internal
+
+    def is_adjacent(self):
+        return self.kind == self.Kind.Adjacent
+
+    def is_untagged(self):
+        return self.kind == self.Kind.Untagged
+
+    @classmethod
+    def is_taggable(cls, typ):
+        return dataclasses.is_dataclass(typ)
+
+    def check(self):
+        if self.is_internal() and self.tag is None:
+            raise SerdeError("\"tag\" must be specified in InternalTagging")
+        if self.is_adjacent() and (self.tag is None or self.content is None):
+            raise SerdeError("\"tag\" and \"content\" must be specified in AdjacentTagging")
+
+
+ExternalTagging = Tagging()
+
+InternalTagging = functools.partial(Tagging, kind=Tagging.Kind.Internal)
+
+AdjacentTagging = functools.partial(Tagging, kind=Tagging.Kind.Adjacent)
+
+Untagged = Tagging(kind=Tagging.Kind.Untagged)
+
+DefaultTagging = ExternalTagging
+
+
+def ensure(expr, description):
+    if not expr:
+        raise Exception(description)
diff --git a/serde/de.py b/serde/de.py
index a08cbce..a59fd17 100644
--- a/serde/de.py
+++ b/serde/de.py
@@ -40,12 +40,14 @@ from .core import (
     SERDE_SCOPE,
     UNION_DE_PREFIX,
     DateTimeTypes,
+    DefaultTagging,
     Field,
     SerdeScope,
     StrSerializableTypes,
+    Tagging,
     add_func,
+    ensure,
     fields,
-    filter_scope,
     logger,
     raise_unsupported_type,
     union_func_name,
@@ -110,6 +112,7 @@ def deserialize(
     rename_all: Optional[str] = None,
     reuse_instances_default: bool = True,
     deserializer: Optional[DeserializeFunc] = None,
+    tagging: Tagging = DefaultTagging,
     **kwargs,
 ):
     """
@@ -170,6 +173,8 @@ def deserialize(
     """
 
     def wrap(cls: Type):
+        tagging.check()
+
         # If no `dataclass` found in the class, dataclassify it automatically.
         if not is_dataclass(cls):
             dataclass(cls)
@@ -190,6 +195,7 @@ def deserialize(
         g['SerdeError'] = SerdeError
         g['raise_unsupported_type'] = raise_unsupported_type
         g['typename'] = typename  # used in union functions
+        g['ensure'] = ensure
         if deserialize:
             g['serde_custom_class_deserializer'] = functools.partial(
                 serde_custom_class_deserializer, custom=deserializer
@@ -204,7 +210,9 @@ def deserialize(
         # render all union functions
         for union in iter_unions(cls):
             union_args = type_args(union)
-            add_func(scope, union_func_name(UNION_DE_PREFIX, union_args), render_union_func(cls, union_args), g)
+            add_func(
+                scope, union_func_name(UNION_DE_PREFIX, union_args), render_union_func(cls, union_args, tagging), g
+            )
 
         # Collect default values and default factories used in the generated code.
         for f in defields(cls):
@@ -695,7 +703,6 @@ def {{func}}(data, reuse_instances = {{serde_scope.reuse_instances_default}}):
     env = jinja2.Environment(loader=jinja2.DictLoader({'iter': template}))
     env.filters.update({'rvalue': renderer.render})
     env.filters.update({'arg': to_iter_arg})
-    env.filters.update({'filter_scope': filter_scope})
     return env.get_template('iter').render(func=FROM_ITER, serde_scope=getattr(cls, SERDE_SCOPE), fields=defields(cls))
 
 
@@ -719,29 +726,42 @@ def {{func}}(data, reuse_instances = {{serde_scope.reuse_instances_default}}):
     env = jinja2.Environment(loader=jinja2.DictLoader({'dict': template}))
     env.filters.update({'rvalue': renderer.render})
     env.filters.update({'arg': functools.partial(to_arg, rename_all=rename_all)})
-    env.filters.update({'filter_scope': filter_scope})
     return env.get_template('dict').render(func=FROM_DICT, serde_scope=getattr(cls, SERDE_SCOPE), fields=defields(cls))
 
 
-def render_union_func(cls: Type, union_args: List[Type]) -> str:
+def render_union_func(cls: Type, union_args: List[Type], tagging: Tagging = DefaultTagging) -> str:
     template = """
 def {{func}}(data, reuse_instances):
-  # create fake dict so we can reuse the normal render function
-  fake_dict = {"fake_key":data}
-
   errors = []
   {% for t in union_args %}
-  {% if t | is_primitive or t | is_none %}
-  if isinstance(data, {{t.__name__}}):
-    return {{t|arg|rvalue}}
-  else:
-    errors.append("input is not of type {{t.__name__}}")
-  {% else %}
   try:
+    # create fake dict so we can reuse the normal render function
+    {% if tagging.is_external() and is_taggable(t)  %}
+    ensure("{{t|typename}}" in data , "'{{t|typename}}' key is not present")
+    fake_dict = {"fake_key": data["{{t|typename}}"]}
+
+    {% elif tagging.is_internal() and is_taggable(t) %}
+    ensure("{{tagging.tag}}" in data , "'{{tagging.tag}}' key is not present")
+    ensure("{{t|typename}}" == data["{{tagging.tag}}"], "tag '{{t|typename}}' isn't found")
+    fake_dict = {"fake_key": data}
+
+    {% elif tagging.is_adjacent() and is_taggable(t) %}
+    ensure("{{tagging.tag}}" in data , "'{{tagging.tag}}' key is not present")
+    ensure("{{tagging.content}}" in data , "'{{tagging.content}}' key is not present")
+    ensure("{{t|typename}}" == data["{{tagging.tag}}"], "tag '{{t|typename}}' isn't found")
+    fake_dict = {"fake_key": data["{{tagging.content}}"]}
+
+    {% else %}
+    fake_dict = {"fake_key": data}
+    {% endif %}
+
+    {% if t|is_primitive or t |is_none %}
+    if not isinstance(fake_dict["fake_key"], {{t.__name__}}):
+        raise Exception("Not a type of {{t.__name__}}")
+    {% endif %}
     return {{t|arg|rvalue}}
   except Exception as e:
     errors.append(f' Failed to deserialize into {{t.__name__}}: {e}')
-  {% endif %}
   {% endfor %}
   raise SerdeError("Can not deserialize " + repr(data) + " of type " + \
           typename(type(data)) + " into {{union_name}}.\\nReasons:\\n" + "\\n".join(errors))
@@ -756,10 +776,12 @@ def {{func}}(data, reuse_instances):
     env.filters.update({'rvalue': renderer.render})
     env.filters.update({'is_primitive': is_primitive})
     env.filters.update({'is_none': is_none})
-    env.filters.update({'filter_scope': filter_scope})
+    env.filters.update({'typename': typename})
     return env.get_template('dict').render(
         func=union_func_name(UNION_DE_PREFIX, union_args),
         serde_scope=getattr(cls, SERDE_SCOPE),
         union_args=union_args,
         union_name=union_name,
+        tagging=tagging,
+        is_taggable=Tagging.is_taggable,
     )
diff --git a/serde/se.py b/serde/se.py
index a9a4235..fcefdcc 100644
--- a/serde/se.py
+++ b/serde/se.py
@@ -40,13 +40,14 @@ from .core import (
     TO_ITER,
     UNION_SE_PREFIX,
     DateTimeTypes,
+    DefaultTagging,
     Field,
     SerdeScope,
     StrSerializableTypes,
+    Tagging,
     add_func,
     conv,
     fields,
-    filter_scope,
     is_instance,
     logger,
     raise_unsupported_type,
@@ -116,6 +117,7 @@ def serialize(
     reuse_instances_default: bool = True,
     convert_sets_default: bool = False,
     serializer: Optional[SerializeFunc] = None,
+    tagging: Tagging = DefaultTagging,
     **kwargs,
 ):
     """
@@ -176,6 +178,8 @@ def serialize(
     """
 
     def wrap(cls: Type):
+        tagging.check()
+
         # If no `dataclass` found in the class, dataclassify it automatically.
         if not is_dataclass(cls):
             dataclass(cls)
@@ -216,7 +220,7 @@ def serialize(
         for union in iter_unions(cls):
             union_args = type_args(union)
             union_key = union_func_name(UNION_SE_PREFIX, union_args)
-            add_func(scope, union_key, render_union_func(cls, union_args), g)
+            add_func(scope, union_key, render_union_func(cls, union_args, tagging), g)
             scope.union_se_args[union_key] = union_args
 
         for f in sefields(cls):
@@ -408,7 +412,6 @@ def {{func}}(obj, reuse_instances = {{serde_scope.reuse_instances_default}},
     renderer = Renderer(TO_ITER, custom)
     env = jinja2.Environment(loader=jinja2.DictLoader({'iter': template}))
     env.filters.update({'rvalue': renderer.render})
-    env.filters.update({'filter_scope': filter_scope})
     return env.get_template('iter').render(func=TO_ITER, serde_scope=getattr(cls, SERDE_SCOPE), fields=sefields(cls))
 
 
@@ -444,18 +447,35 @@ def {{func}}(obj, reuse_instances = {{serde_scope.reuse_instances_default}},
     env.filters.update({'rvalue': renderer.render})
     env.filters.update({'lvalue': lrenderer.render})
     env.filters.update({'case': functools.partial(conv, case=case)})
-    env.filters.update({'filter_scope': filter_scope})
     return env.get_template('dict').render(func=TO_DICT, serde_scope=getattr(cls, SERDE_SCOPE), fields=sefields(cls))
 
 
-def render_union_func(cls: Type, union_args: List[Type]) -> str:
+def render_union_func(cls: Type, union_args: List[Type], tagging: Tagging = DefaultTagging) -> str:
+    """
+    Render function that serializes a field with union type.
+    """
     template = """
 def {{func}}(obj, reuse_instances, convert_sets):
   union_args = serde_scope.union_se_args['{{func}}']
 
   {% for t in union_args %}
   if is_instance(obj, union_args[{{loop.index0}}]):
-    return {{t|arg|rvalue()}}
+    {% if tagging.is_external() and is_taggable(t) %}
+    return {"{{t|typename}}": {{t|arg|rvalue}}}
+
+    {% elif tagging.is_internal() and is_taggable(t) %}
+    res = {{t|arg|rvalue}}
+    res["{{tagging.tag}}"] = "{{t|typename}}"
+    return res
+
+    {% elif tagging.is_adjacent() and is_taggable(t) %}
+    res = {"{{tagging.content}}": {{t|arg|rvalue}}}
+    res["{{tagging.tag}}"] = "{{t|typename}}"
+    return res
+
+    {% else %}
+    return {{t|arg|rvalue}}
+    {% endif %}
   {% endfor %}
   raise SerdeError("Can not serialize " + repr(obj) + " of type " + typename(type(obj)) + " for {{union_name}}")
     """
@@ -465,12 +485,14 @@ def {{func}}(obj, reuse_instances, convert_sets):
     env = jinja2.Environment(loader=jinja2.DictLoader({'dict': template}))
     env.filters.update({'arg': lambda x: SeField(x, "obj")})
     env.filters.update({'rvalue': renderer.render})
-    env.filters.update({'filter_scope': filter_scope})
+    env.filters.update({'typename': typename})
     return env.get_template('dict').render(
         func=union_func_name(UNION_SE_PREFIX, union_args),
         serde_scope=getattr(cls, SERDE_SCOPE),
         union_args=union_args,
         union_name=union_name,
+        tagging=tagging,
+        is_taggable=Tagging.is_taggable,
     )
 
 


Test patch:
diff --git a/tests/test_union.py b/tests/test_union.py
index 21aeb3a..380db72 100644
--- a/tests/test_union.py
+++ b/tests/test_union.py
@@ -325,3 +325,226 @@ def test_union_with_union_in_nested_tuple():
     a_int_dict = {"v": (1,)}
     assert to_dict(a_int) == a_int_dict
     assert from_dict(A, a_int_dict) == a_int
+
+
+def test_external_tagging():
+    @serde
+    class Bar:
+        b: int
+
+    @serde
+    class Baz:
+        b: int
+
+    @serde
+    class Nested:
+        v: Union[Bar, Baz]
+
+    @serde
+    class Foo:
+        a: Union[Bar, Baz]
+        b: Union[int, str]
+        c: Union[Dict[int, str], List[int]]
+        d: Union[int, Nested]
+
+    f = Foo(Bar(10), 'foo', {10: 'bar'}, Nested(Baz(100)))
+    d = {
+        "a": {"Bar": {"b": 10}},  # Union of dataclasses will be (de)serialized with external tagging
+        "b": "foo",  # non dataclass will be untagged
+        "c": {10: "bar"},
+        "d": {"Nested": {"v": {"Baz": {"b": 100}}}},
+    }
+    assert to_dict(f) == d
+    assert from_dict(Foo, d) == f
+
+    @serde
+    class Foo:
+        a: Union[Bar, int]  # Mix of dataclass and non dataclass
+
+    f = Foo(Bar(10))
+    assert from_dict(Foo, to_dict(f)) == f
+    f = Foo(10)
+    assert from_dict(Foo, to_dict(f)) == f
+
+    @serde
+    class Foo:
+        a: Union[Bar, Baz]
+
+    f = Foo(Bar(10))
+
+    # Tag not found
+    with pytest.raises(Exception):
+        assert from_dict(Foo, {"a": {"TagNotFound": {"b": 10}}})
+
+    # Tag is correct, but incompatible data
+    with pytest.raises(Exception):
+        assert from_dict(Foo, {"a": {"Bar": {"c": 10}}})
+
+
+def test_internal_tagging():
+    from serde import InternalTagging
+
+    @serde
+    class Bar:
+        v: int
+
+    @serde
+    class Baz:
+        v: int
+
+    @serde(tagging=InternalTagging("type"))
+    class Nested:
+        v: Union[Bar, Baz]
+
+    @serde(tagging=InternalTagging("type"))
+    class Foo:
+        a: Union[Bar, Baz]
+        b: Union[int, str]
+        c: Union[Dict[int, str], List[int]]
+        d: Union[int, Nested]
+
+    f = Foo(Bar(10), 'foo', {10: 'bar'}, Nested(Baz(100)))
+    d = {
+        "a": {"type": "Bar", "v": 10},  # Union of dataclasses will be (de)serialized with internal tagging
+        "b": "foo",  # non dataclass will be untagged
+        "c": {10: "bar"},
+        "d": {"type": "Nested", "v": {"type": "Baz", "v": 100}},
+    }
+    assert to_dict(f) == d
+    assert from_dict(Foo, d) == f
+
+    @serde(tagging=InternalTagging("type"))
+    class Foo:
+        a: Union[Bar, Baz]
+
+    f = Foo(Bar(10))
+
+    # Tag not found
+    with pytest.raises(Exception):
+        assert from_dict(Foo, {"a": {"TagNotFound": "", "v": 10}})
+
+    # Tag is correct, but incompatible data
+    with pytest.raises(Exception):
+        assert from_dict(Foo, {"a": {"type": "Bar", "c": 10}})
+
+    with pytest.raises(SerdeError):
+        # Tag is not specified in attribute
+        @serde(tagging=InternalTagging())
+        class Foo:
+            pass
+
+
+def test_adjacent_tagging():
+    from serde import AdjacentTagging
+
+    @serde
+    class Bar:
+        v: int
+
+    @serde
+    class Baz:
+        v: int
+
+    @serde(tagging=AdjacentTagging("type", "content"))
+    class Nested:
+        v: Union[Bar, Baz]
+
+    @serde(tagging=AdjacentTagging("type", "content"))
+    class Foo:
+        a: Union[Bar, Baz]
+        b: Union[int, str]
+        c: Union[Dict[int, str], List[int]]
+        d: Union[int, Nested]
+
+    f = Foo(Bar(10), 'foo', {10: 'bar'}, Nested(Baz(100)))
+    d = {
+        "a": {"type": "Bar", "content": {"v": 10}},  # Union of dataclasses will be (de)serialized with adjacent tagging
+        "b": "foo",  # non dataclass will be untagged
+        "c": {10: "bar"},
+        "d": {"type": "Nested", "content": {"v": {"type": "Baz", "content": {"v": 100}}}},
+    }
+    assert to_dict(f) == d
+    assert from_dict(Foo, d) == f
+
+    @serde(tagging=AdjacentTagging("type", "content"))
+    class Foo:
+        a: Union[Bar, Baz]
+
+    f = Foo(Bar(10))
+
+    # Tag not found
+    with pytest.raises(Exception):
+        assert from_dict(Foo, {"a": {"TagNotFound": "", "content": {"v": 10}}})
+
+    # Content tag not found
+    with pytest.raises(Exception):
+        assert from_dict(Foo, {"a": {"type": "Bar", "TagNotFound": {"v": 10}}})
+
+    # Tag is correct, but incompatible data
+    with pytest.raises(Exception):
+        assert from_dict(Foo, {"a": {"type": "Bar", "content": {"c": 10}}})
+
+    with pytest.raises(SerdeError):
+        # Tag is not specified in attribute
+        @serde(tagging=AdjacentTagging(content="content"))
+        class Foo:
+            pass
+
+    with pytest.raises(SerdeError):
+        # Content is not specified in attribute
+        @serde(tagging=AdjacentTagging(tag="tag"))
+        class Foo:
+            pass
+
+    with pytest.raises(SerdeError):
+        # Tag/Content is not specified in attribute
+        @serde(tagging=AdjacentTagging())
+        class Foo:
+            pass
+
+
+def test_untagged():
+    from serde import Untagged
+
+    @serde
+    class Bar:
+        v: int
+
+    @serde
+    class Baz:
+        v: int
+
+    @serde(tagging=Untagged)
+    class Nested:
+        v: Union[Bar, Baz]
+
+    @serde(tagging=Untagged)
+    class Foo:
+        a: Union[Bar, Baz]
+        b: Union[int, str]
+        c: Union[Dict[int, str], List[int]]
+        d: Union[int, Nested]
+
+    f = Foo(Bar(10), 'foo', {10: 'bar'}, Nested(Bar(100)))
+    d = {"a": {"v": 10}, "b": "foo", "c": {10: "bar"}, "d": {"v": {"v": 100}}}
+    assert to_dict(f) == d
+    assert from_dict(Foo, d) == f
+
+    @serde(tagging=Untagged)
+    class Foo:
+        a: Union[Bar, Baz]
+
+    f = Foo(Baz(10))
+
+    # Untagged can't differenciate the dataclass with similar fields
+    with pytest.raises(Exception):
+        assert to_dict(from_dict(Foo, d)) == f
+
+    # Untaggled can't differenciate Dict and List.
+    @serde
+    class Foo:
+        a: Union[List[int], Dict[int, str]]
+
+    f = Foo({10: 'bar'})
+    with pytest.raises(Exception):
+        assert to_dict(from_dict(Foo, d)) == f