Repository: wemake-services/wemake-python-styleguide

Issue:
Forbid `Optional[Union[A, B]]`, use `Union[A, B, None]` instead
# Rule request

<!-- Thanks for coming up with a proposal! -->

## Thesis

<!-- What exactly do you propose? Please, provide code examples. -->

These two types are equal: `Optional[Union[A, B]] == Union[A, B, None]`

For consistency reasons we need to enforce the second form over the first one.
Related to #792 


Discussion:
Hi, I'd like to try to implement this. 
Thanks, @ko3luhbka! You are on it.
I'm working on it, just have some stupid issues with my environment.
@ko3luhbka thanks! Do you need any help?
@sobolevn well, yes, I actually think that it's time to ask for help :).
I'm sure that it's very simple issue.
So, I just want to make some tracking of what's happening in ../visitors/ast/annotations.py and other modules, by now is just adding ``print`` statements here and there. I've created 'ex.py' file in the project's root dir with code violating a lot of WPS rules (like short variable names, f-string using and some others), and run 'flake8 ex.py' expecting to see WPS violations there. But I see all violations exept WPS. At first I thought that it's maybe a dependency issue and 'wemake-python-styleguide' plugin haven't been installed. But there's a message at the bottom of output : 
``Full list of violations and explanations:
https://wemake-python-stylegui.de/en/0.12.4/pages/usage/violations/,`` 
so plugin is enabled, I guess. 'flake8 --version' outputs 'wemake-python-styleguide: 0.12.4' among others as well.
At the same time, if I run 'flake8 .' from project's root directory, there are WPS errors complaining about some code I started to write to solve the actual issue (#875). 
I didn't change anything in project's 'setup.cfg' and I don't have any global flake8 config files.
I use MacOS Mojave 10.14.6, pyenv virtualenv with Python 3.7.4.
Do you have any ideas what I'm doing wrong?
@sobolevn I finally found what causes this.
My ``ex.py`` test file contains syntax error:
`def invalid_func()`
i.e. function without body and colon after brackets. In this case ``wemake-python-styleguide`` doesn't work while other plugins do.
Isn't it a bug? I know it's hardly a usecase and flake8 actually complained about that ("E999  SyntaxError: invalid syntax").
No, it is not a bug. That's how it should be. We cannot work with syntax errors. That's what `flake8` does for us. I am glad that your problem is fixed now.

Sorry that I was not able to help you earlier.
@sobolevn thank you for your patience, and actually it was a good lesson for me.

Resolving patch:
diff --git a/wemake_python_styleguide/violations/annotations.py b/wemake_python_styleguide/violations/annotations.py
--- a/wemake_python_styleguide/violations/annotations.py
+++ b/wemake_python_styleguide/violations/annotations.py
@@ -18,12 +18,14 @@
 
    LiteralNoneViolation
    NestedAnnotationsViolation
+   UnionNestedInOptionalViolation
 
 Annotation checks
 ------------------
 
 .. autoclass:: LiteralNoneViolation
 .. autoclass:: NestedAnnotationsViolation
+.. autoclass:: UnionNestedInOptionalViolation
 
 """
 
@@ -77,6 +79,7 @@ class NestedAnnotationsViolation(ASTViolation):
         Flatten consecutively nested ``typing.Literal`` and ``typing.Union``.
 
     Example::
+
         # Correct:
         Literal[1, 2, 3, "foo", 5, None]
         Union[int, str, float]
@@ -91,3 +94,31 @@ class NestedAnnotationsViolation(ASTViolation):
 
     error_template = 'Found redundant nested typing annotation'
     code = 702
+
+
+@final
+class UnionNestedInOptionalViolation(ASTViolation):
+    """
+    Forbids to use ``Optional[Union[int, str]]`` annotation.
+
+    Reasoning:
+        Optional[Union[int, str]] equals to Union[int, str, None].
+        Use Union[int, str, None] version for consistency.
+
+    Solution:
+        Replace ``Optional[Union[int, str]]`` with ``Union[int, str, None]``.
+
+    Example::
+
+        # Correct:
+        Union[int, str, None]
+
+        # Wrong:
+        Optional[Union[int, str]]
+
+    .. versionadded:: 0.13.0
+
+    """
+
+    error_template = 'Found typing annotation with `Union` nested in `Optional`'
+    code = 704
diff --git a/wemake_python_styleguide/visitors/ast/annotations.py b/wemake_python_styleguide/visitors/ast/annotations.py
--- a/wemake_python_styleguide/visitors/ast/annotations.py
+++ b/wemake_python_styleguide/visitors/ast/annotations.py
@@ -10,6 +10,7 @@
 from wemake_python_styleguide.violations.annotations import (
     LiteralNoneViolation,
     NestedAnnotationsViolation,
+    UnionNestedInOptionalViolation,
 )
 from wemake_python_styleguide.violations.consistency import (
     MultilineFunctionAnnotationViolation,
@@ -94,6 +95,7 @@ def _check_annotation(self, annotation: Optional[ast.expr]) -> None:
 
         self._check_nested_annotations(annotation)
         self._check_literal_none(annotation)
+        self._check_union_nested_in_optional(annotation)
 
     def _check_nested_annotations(self, annotation: ast.expr) -> None:
         annotation_string = self._get_annotation(annotation)
@@ -106,6 +108,11 @@ def _check_literal_none(self, annotation: ast.expr) -> None:
         if 'Literal[None]' in annotation_string:
             self.add_violation(LiteralNoneViolation(annotation))
 
+    def _check_union_nested_in_optional(self, annotation: ast.expr) -> None:
+        annotation_string = self._get_annotation(annotation)
+        if 'Optional[Union[' in annotation_string:
+            self.add_violation(UnionNestedInOptionalViolation(annotation))
+
 
 @final
 @alias('visit_any_function', (


Test patch:
diff --git a/tests/fixtures/noqa.py b/tests/fixtures/noqa.py
--- a/tests/fixtures/noqa.py
+++ b/tests/fixtures/noqa.py
@@ -573,14 +573,21 @@ def literal_none_return_func() -> Literal[None]:  # noqa: WPS701
     """Literal[None]"""
 
 
-def nested_annotation_func(arg: Literal[Literal[1, 2], 3]): # noqa: WPS702
+def nested_annotation_func(arg: Literal[Literal[1, 2], 3]):  # noqa: WPS702
     """Literal[Literal[1, 2], 3]"""
 
 
-def nested_annotation_return_func() -> Union[str, Union[int, float]]: # noqa: WPS702
+def nested_annotation_return_func() -> Union[str, Union[int, float]]:  # noqa: WPS702
     """Union[str, Union[int, float]]"""
 
 
+def optional_union_annotation_func(arg: Optional[Union[str, int]]):  # noqa: WPS704
+    """Optional[Union[str, int]]"""
+
+
+def optional_union_annotation_return_func() -> Optional[Union[str, int]]:  # noqa: WPS704
+    """Optional[Union[str, int]]"""
+
 from json import loads  # noqa: WPS347
 from some_module import a  # noqa: WPS347
 from text import from_file  # noqa: WPS347
diff --git a/tests/test_checker/test_noqa.py b/tests/test_checker/test_noqa.py
--- a/tests/test_checker/test_noqa.py
+++ b/tests/test_checker/test_noqa.py
@@ -215,6 +215,7 @@
 
     'WPS701': 2,
     'WPS702': 2,
+    'WPS704': 2,
 })
 
 
diff --git a/tests/test_visitors/test_ast/test_annotations/test_annotation_metadata/test_flat_types.py b/tests/test_visitors/test_ast/test_annotations/test_annotation_metadata/test_flat_types.py
--- a/tests/test_visitors/test_ast/test_annotations/test_annotation_metadata/test_flat_types.py
+++ b/tests/test_visitors/test_ast/test_annotations/test_annotation_metadata/test_flat_types.py
@@ -7,6 +7,7 @@
 )
 from wemake_python_styleguide.visitors.ast.annotations import (
     SemanticAnnotationVisitor,
+    UnionNestedInOptionalViolation,
 )
 
 
@@ -101,3 +102,50 @@ def test_correct_nested_annotations(
     visitor.run()
 
     assert_errors(visitor, [])
+
+
+@pytest.mark.parametrize('code', [
+    'Optional[Union[int, str]]',
+    'Optional[Optional[Optional[Union[int, str]]]]',
+    'typing.Optional[typing.Union[int, float]]',
+    'typing.Optional[typing.Optional[typing.Optional[typing.Union[int, str]]]]',
+])
+def test_wrong_union_nested_in_optional(
+    assert_errors,
+    parse_ast_tree,
+    code,
+    annotation_template,
+    default_options,
+    mode,
+):
+    """Ensures that `Union` nested in `Optional` annotations are forbidden."""
+    tree = parse_ast_tree(mode(annotation_template.format(code)))
+
+    visitor = SemanticAnnotationVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(visitor, [UnionNestedInOptionalViolation])
+
+
+@pytest.mark.parametrize('code', [
+    'Optional[str]',
+    'typing.Optional[int]',
+    'Union[int, str, None]',
+    'typing.Union[int, float, None]',
+    'Optional[Any]',
+])
+def test_correct_optional_without_nested_union(
+    assert_errors,
+    parse_ast_tree,
+    code,
+    annotation_template,
+    default_options,
+    mode,
+):
+    """Ensures that `Optional` without nested `Union` is ok."""
+    tree = parse_ast_tree(mode(annotation_template.format(code)))
+
+    visitor = SemanticAnnotationVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(visitor, [])