1. 51
  1.  

  2. 6

    This article is great!

    I just want to say to anyone thinking of adapting something similar: do not use assert in production code. It’s both slow and will be ignored/removed when running python with the -O (optimize) flag. Instead, just raise the exception. If you’re doing exactly what’s being done here, it’s fine as it should only ever be called as part of the ‘compile-time’ type checking, but otherwise assert should be avoided outside of tests.

    1. 7

      In my understanding, pretty much the only thing -O flag does is removing asserts, so IMO it makes it basically useless.

      And I can’t really come up with the situation when having asserts would have performance impact, unless you’ve got some contrived example riddled with asserts and doing nothing else apart from some CPU arithmetic. Of course also possible that assert conditions are slow by themselves, but anyway, replacing them with exceptions in such case isn’t gonna improve the performance.

      1. 2

        That’s the scenario right now, indeed.

        1. 2

          I reworded it to be a bit more clear, but yes this scenario is perfectly fine. I wanted to point out to people who may not fully understand some gotchas with using assert.

        2. 2

          Good point! I updated the article to address this issue:

          https://hakibenita.com/python-mypy-exhaustive-checking#updates

          1. 1

            Cool! Great article and I look forward to reading more from you!

        3. 7

          I really enjoyed this article because of how practical it is. I feel like this is a technique I can apply immediately.

          1. 4

            Definitely!

            The concept was borrowed from typescript:

            https://twitter.com/be_haki/status/1219248816127385600?s=19

          2. 4

            This is cool and all but it’s a bit of a downer that strictly worse mainstream languages are adopting features from better languages and implementing them in a strictly worse way. E.g. in OCaml you would get an exhaustiveness error like:

            Warning 8: this pattern-matching is not exhaustive.
            Here is an example of a case that is not matched:
            Scheduled
            

            Instead of the inscrutable:

            error: Argument 1 to "assert_never" has incompatible type "Literal[OrderStatus.Scheduled]";
            expected "NoReturn"
            

            (Also, you wouldn’t need to remember to put an else: assert_never(...) at the end of the code.)

            1. 6

              In OCaml pattern matching is a core feature, here it’s being emulated by clever users. There’s a proposal to add pattern matching to Python 3.10, in which case presumably Mypy would be able to do exhaustiveness checking.

              (Also, calling Python “strictly worse” is a bit of a strong statement. Strictly worse how?)

              1. 2

                Yes, eventually–if and when the proposal is accepted, then ships in Python, then gets implemented and ships in Mypy.

                Strictly worse how?

                • Performance
                • Type safety
                • Language design (including modules), e.g. the recently-added controversial walrus operator in Python is something that automatically and naturally occurs in OCaml due to its original design

                But since this is a programming-language-oriented thread, it’s reasonable to assume that we’re comparing mostly language design.

                1. 7

                  On the other hand, Python runs natively on Windows, so it’s strictly superior to OCaml as a language.

                  (F# is another story…)

                  1. 5

                    Difficult to tell whether this is a serious assertion, but on the off chance:

                    • OCaml has no fewer than three ports to Windows. If this seems like a disadvantage, remember that many people actually recommend Anaconda as an alternative to the official Python installer on Windows.
                    • The most well-known port, OCaml for Windows, provides recent compiler versions and almost all opam (equivalent of PyPI) packages.
                    • Opam is planning full Windows support out of the box in an upcoming release.
                    1. 6

                      It was tongue-in-cheek up until I read your response, which made it suddenly an excellent argument about why “strictly superior” is nonsense. All three of your ports need “a Unix-like build environment”. Package manager support is only “planned” in the next version, much as pattern matching is “planned” for 3.10. Comparing it to Anaconda, which is recommended to scientists and data scientists who aren’t programmers, is flat wrong. The only people who could think that OCaml Windows support is as good as Python Windows support, which doesn’t require you to install cygwin, are OCaml partisans.

                      Admitting that OCaml has worse Windows support than Python is the easiest thing in the world. If you’re not willing to even do that, why should I trust you about any of your other claims?

                      1. 2

                        Comparing it to Anaconda, which is recommended to scientists and data scientists who aren’t programmers, is flat wrong.

                        Let’s actually look past the marketing blurb and check how Anaconda describes itself:

                        ’Anaconda Individual Edition is a free, easy-to-install package manager, environment manager, and Python distribution with a collection of 1,500+ open source packages with free community support.

                        ’Anaconda Commercial Edition is the world’s most popular open-source package distribution and management experience, optimized for commercial use and compliance with our Terms of Service.

                        ’Anaconda Team Edition is our latest generation repository for all things Anaconda. With support for all major operating systems, the repository serves as your central conda, PyPI, and CRAN packaging resource for desktop users, development clusters, CI/CD systems, and production containers.

                        ‘Anaconda Enterprise is an enterprise-ready, secure, and scalable data science platform…’

                        (From https://docs.anaconda.com/ )

                        So interestingly, they position only their enterprise edition as a data science platform, and the others as general-purpose Python distributions.

                        Now we can make the argument that it’s still used mainly by data scientists. But that just proves my point! Data scientists, like most other computer users, are mostly using Windows. So why would they prefer Anaconda, which is (if we ignore the Enterprise edition) just a Python distro? Because it has an easy Windows installer which loads you up with commonly-used libraries! I.e. specific distribution for a specific need, just like the OCaml Windows ports.

                        Admitting that OCaml has worse Windows support than Python is the easiest thing in the world. If you’re not willing to even do that, why should I trust you about any of your other claims?

                        That’s a non sequitur. I was responding to your claim that:

                        Python runs natively on Windows

                        …with the implication that OCaml does not. You’re a smart guy, you probably already know that it does and has for a long time. I was just expanding on that and saying that the surrounding tooling is also catching up.

                        Also, since when is Windows (a non-free operating system) support a measure of the quality of a programming language? Is this a portability argument in disguise? Are you suggesting in turn that OCaml, which has been ported and released for Apple Silicon, is better than languages which have not, and which due to resource constraints, won’t be for a while? Seems like a nonsensical argument.

                        1. 2

                          And let’s take at the OCaml for Windows page:

                          opam-repository-mingw provides an opam repository for Windows - and an experimental build of opam for Windows. It is work in progress, but it already works well enough to install packages with complex dependencies (like core_kernel) and packages with external dependencies (e.g lablgtk).

                          The repository is forked from the standard version. It contains Windows specific patches and build instructions. Especially build related tools (ocamlfind, ocamlbuild, omake, oasis, opam and OCaml itself) were modified, so that most unix-centric build instructions will also work with the native Windows/OCaml toolchain and I can sync the repository with the main repo from time to time (and without too much hassle).

                          I don’t know about you, but this doesn’t stir confidence in me that I’ll be up and confidently running OCaml in Windows. Which brings up the question again, what does it mean to be “strictly superior”?

                          Also, since when is Windows (a non-free operating system) support a measure of the quality of a programming language?

                          What does Windows being non-free have to do with being strictly superior?

                          Is this a portability argument in disguise

                          Since nobody in this thread has defined what “strictly superior” means, why can’t it be?

                          1. 1

                            What does Windows being non-free have to do with being strictly superior?

                            Non-free operating systems don’t have anything to do with language ‘superiority’ at all.

                            nobody in this thread has defined what “strictly superior” means

                            ‘Strictly superior’ is a subjective, and therefore, opinionated argument. So to each their own, but to me:

                            • Statically typed (meaning, no typechecking at runtime)
                            • Pattern matching
                            • Exhaustivity checking built in
                            • Every object/method/value is not a key/value in a bunch of dicts which can be runtime-patched
                            • No nulls
                            • Immutable values
                            • Expression-oriented syntax
                2. 2

                  Fwiw, Java supports that kind of exhaustiveness checking now too, added in JDK 13 as part of switch expressions. Seems to be becoming a more common feature even outside of functional programming.

                  1. 1

                    That’s the thing–all features from functional programming (starting from garbage collection in the ’50s) eventually find their way into ‘mainstream’ programming languages, all the while FP is called ‘academic’ and ‘impractical’ every step of the way 😂

                    1. 1

                      You can say the same about OO languages. After all, Simula and Smalltalk were certainly not widely popular. C++ first appeared in 1985, which was a good 25 years after Algol-60 was released. And FP languages have gotten plenty wrong. Haskell’s laziness has been criticized for years, and I’d be happier if I didn’t have to deal with Scala implicits in my code again.

                3. 3

                  “Not exhaustive” error messages when dealing with non-trivial types are amazing. I sit there and look at the example trying to figure it out then when I do half the time it is a bug that would take a lot of time to find.

                  1. 2

                    It’s a much better value proposition to add something like this that provides, let’s be conservative, half the utility, to an existing codebase than it is to switch languages (and ecosystems, tools, and so forth) in order to get the full utility.

                    At some point the definitions of “better” and “worse” need to be weighted based on how much work is actually accomplished with a given tool / language.

                    1. 2

                      Hopefully also weighted by a fair accounting of the amount of bugs prevented by the language.

                      1. 2

                        Don’t listen to him! It’s a trap! It’s totally not fair to allow OCaml and friends to put the bugs prevented by the language on their side of the ledger because those are several infinite classes of bugs.

                    2. 2

                      My counterpoint is that OCaml only provides this feature through having a purpose-built ADT for it.

                      Mypy (and typescript) generalize these concepts by slowing arbitrary union of types. You can have “string or bytes” instead of “Either String Bytes” (with the wrapping required at call sites)

                      From a usability perspective OCamls stuff is way more restrictive and verbose

                      1. 1

                        I guess it’s possible with a suitable type system, e.g. in Scala you can have an implicit that coercing the value into Either, or in C++ it could work via an implicit constructor. But yeah, to me was somewhat surprising to find myself (as a big fan of types!) enjoying mypy and other gradual typing systems more than ‘stricter’/‘static’ ones.

                        1. 1

                          Arbitrary unions of types turn out to be a bad idea as soon as you need to deal with generics. Difficult to work with A | B if A and B are generics.

                      2. 2

                        Very nice writeup, especially the trick with NoReturn!

                        I also find type narrowing extremely useful for error handling, you can use Union[Exception, T] as the result type, and ‘pattern match’ with isinstance, which works in runtime and checkable by mypy. In addition, covariant Union type lets use it with generators and consume without extra boilerplate. I write more about it here

                        1. 2

                          So go(lang) in Python? 😉

                          1. 4

                            go returns a Tuple[Exception, T]

                            1. 2

                              Yep. I’m describing the Go approach here

                              1. 1

                                Thanks for the article karlicoss. We were just discussing this pattern the other day and we ended up reaching similar conclusions. The only difference in our case was that we wanted to return T along with the exception. This makes the “pattern” of the return type a bit more messy. We need to keep exploring.

                          2. 1

                            You mean returning an Exception instead of raising it?

                            1. 1

                              ah yes, sorry! Too late to edit now :(

                          3. 1

                            Very nice! MyPy’s flow sensitive type checking is indeed powerful.

                            I also like it for the nullable checks, which is related to this argument [1]. If the nullable type is flow sensitive, that’s basically what I want, and it’s useful in real code.

                            [1] https://lobste.rs/s/hek0ym/why_nullable_types

                            1. 1

                              It actually is. The only gotcha is that sometimes you must rewrite your code in order to allow the type checker to figure things out. For instance, consider the two functions in the following snippet:

                              from typing import Dict, Optional
                              
                              def dynamic_option_check(d: Dict[str, Optional[str]]) -> str:
                                  return d['key'] if d.get('key', None) is not None else ''
                              
                              def static_option_check(d: Dict[str, Optional[str]]) -> str:
                                  return d['key'] if 'key' in d and d['key'] is not None else ''
                              

                              … And the following type checker output:

                              $ mypy src/optional_test.py
                              src/optional_test.py:4: error: Incompatible return value type (got "Optional[str]", expected "str")
                              Found 1 error in 1 file (checked 1 source file)
                              

                              Type checking fails for dynamic_option_check() because mypy cannot narrow d’s type from the d.get() call. static_option_check() works fine, though, as we are explictly testing if d['key'] is not None.

                            2. 1

                              Very nice article :) One thing I couldn’t help but look sideways at is the use of NoReturn as a function argument type to assert_never—I know it is not a big deal, but I went to check mypy source code for its internal representation of the bottom type.

                              NoReturn is a possible representation of the bottom type (yes, that’s weird). The bottom type itself is named UninhabitedType. I will spare you the details, but that type is equivalent to an empty union, Union[()], which kind of makes sense. I was surprised Guido himself did not point it out in the respective GitHub issue thread, maybe because that’s a minor detail and he didn’t bother to.

                              So, to please my inner armchair type theorist, I would probably replace that signature by

                              def assert_never(value: 'Union[()]') -> NoReturn: ...
                              

                              Edit: the original snippet was not enough to get it working in runtime; the quotes around the argument type annotation are required. Please check the full explanation in the replies below.

                              The emitted error message also gets slightly more intuitive, since NoReturn is replaced by <nothing>:

                              ~$ mypy src/exhaustiveness_check.py
                              src/exhaustiveness_check.py:18: error: Argument 1 to "assert_never" has incompatible type "Literal[OrderStatus.Scheduled]"; expected <nothing>
                              Found 1 error in 1 file (checked 1 source file)
                              
                              1. 2

                                That’s a great idea! I updated the article with your suggestion

                                https://hakibenita.com/python-mypy-exhaustive-checking#updates

                                1. 2

                                  Hi, just an update because I noticed things get more complex in runtime.

                                  If we use the following annotation format:

                                  def assert_never(value: Union[()]) -> NoReturn: ...
                                  

                                  We get the following error in runtime:

                                  $ python src/exhaustiveness_check.py
                                  Traceback (most recent call last):
                                    File "src/exhaustiveness_check.py", line 9, in <module>
                                      def assert_never(value: Union[()]) -> NoReturn:
                                    File "/usr/lib/python3.7/typing.py", line 251, in inner
                                      return func(*args, **kwds)
                                    File "/usr/lib/python3.7/typing.py", line 344, in __getitem__
                                      raise TypeError("Cannot take a Union of no types.")
                                  TypeError: Cannot take a Union of no types.
                                  

                                  This is because Python actually attempts to construct a typing.Union object to compose the annotation. That can be avoided by having the argument type annotation as a string:

                                  from typing import NoReturn, Union
                                  
                                  def assert_never(value: 'Union[()]') -> NoReturn:
                                      raise AssertionError(f'Unhandled value: {value} ({type(value).__name__})')
                                  

                                  One other option is the snippet below, which is certainly much more verbose than the original solution; besides that, it introduces different function types at compile and runtime.

                                  from typing import NoReturn, Union, TYPE_CHECKING
                                  
                                  if TYPE_CHECKING:
                                      def assert_never(value: Union[()]) -> NoReturn: ...
                                  else:
                                      def assert_never(value) -> NoReturn:
                                          raise AssertionError(f'Unhandled value: {value} ({type(value).__name__})')
                                  

                                  The advantage of these approaches is that at least they do not contradict PEP 484, as NoReturn is only used as a return annotation.

                              2. 0

                                I’ll definitely be sharing this with my team at work. Thanks OP!