Soundness with Mypy, Flow, and Typescript
In the Flow FAQ page there as interesting example centered around Array<string | number>
as a function argument. It will form our first example for comparision between Flow and Typescript as well as Mypy.
Example 1: Covariance
Before we dive into what covariance and invariance mean in practice, let’s look at the examples.
TypeScript (playground)
function foo(array: Array<string | number>) {
return array
}
const bar: Array<string> = ["a", "b", "c"]
foo(bar)
When we run this through TypeScript, with all the strictness knobs turned up, we don’t get any compiler errors. Maybe it isn’t immediately clear why this is a problem so let’s throw the same code into Flow and see if it has any insights.
Flow (playground)
// @flow
function foo(array: Array<string | number>) {
return
}
const bar: Array<string> = ["a", "b", "c"]
foo(bar)
9: foo(arr) // Error!
^ Cannot call `foo` with `bar` bound to `array` because string [1] is incompatible with number [2] in array element.
References:
7: const bar: Array<string> = ["a", "b", "c"]
^ [1]
3: function foo(array: Array<string | number>) {
^ [2]
Flow spots the problem! If we were to pass in an array of type Array<string>
to a function foo(array: Array<string | number>)
, then the function could then .push()
another element onto the array of type number
and now our array is no longer of type Array<string>
but instead of Array<string | number>
.
There are two possible fixes for this, we could change our array we are passing in to be of type Array<string | number>
or make the array immutable via ReadonlyArray<string | number>
. The Flow FAQ highlights the latter solution.
Mypy (playground)
Let’s see if mypy can spot the problem.
from typing import List, Union
def foo(array: List[Union[str, int]]) -> None:
return
bar: List[str] = ["a", "b", "c"]
foo(bar)
Yup, Mypy provides the following error and is even kind enough to provide some docs.
main.py:8: error: Argument 1 to "foo" has incompatible type "List[str]"; expected "List[Union[str, int]]"
main.py:8: note: "List" is invariant -- see http://mypy.readthedocs.io/en/latest/common_issues.html#variance
main.py:8: note: Consider using "Sequence" instead, which is covariant
Found 1 error in 1 file (checked 1 source file)
Looks like we need to change the argument from a List
to a Sequence
, which is a immutable form of a List
. We could also change the type of our list argument to List[Union[str, int]]
.
Real world example
When setting up the new python Sentry SDK, which has type hints, I ran into a similar
issue where the
argument to the sentry_sdk.init
method required List[Integration]
, but I was passing
in List[LoggingIntegration]
where LoggingIntegration
is a subtype of
Integration
. This failed to typecheck for the same reason as the List[Union[str, int]]
examples above.
We can use an example to illustrate.
class LoggerIntegration(Integration):
pass
def foo(array: List[Integration]) -> None:
return
bar: List[LoggerIntegration] = [LoggerIntegration]
foo(bar)
Here we have bar
with type List[LoggerIntegration]
, which is passed into foo()
. If
foo()
was allowed to mutate bar
by appending an Integration()
then bar
would
no longer be of type
List[LoggerIntegration]
but instead List[Union[LoggerIntegration, Integration]]]
.
Once again we can follow the suggestion of mypy
and our experience with
flow
and use immutable lists via a Sequence
.
Example 2: Array Indexing
In JavaScript, when we index into a array position that doesn’t exist we get undefined
. Ultimately, this means indexing into Array<T>
should return T | undefined
.
Unlike the covariance example, Flow and TypeScript don’t warn about this. They probably ignore this because having to check the result of each index operation is more annoying than the added soundness.
// @flow
function head(array: Array<string>): string {
return array[0]
}
No error in Flow or TypeScript. 🤷
In other languages, indexing into an array position that doesn’t exist results into either an exception/panic/fatal error. This means that an array index can correctly be typed as a T
since the type will never be undefined
.
Python raises an IndexError
, Rust has a panic!()
, Swift has a Fatal error: Index out of range
. You get the idea.
This isn’t to say TypeScript or Flow shouldn’t type array indexes as T | undefined
, if they wanted soundness they would need to return possibly undefined
or create a notion of a panic.
Elm is sound and takes the possibly undefined
approach via Maybe
.
https://package.elm-lang.org/packages/elm/core/latest/Array#get
get : Int -> Array a -> Maybe a
Conclusion
Like most things in software, there are trade-offs. Flow and TypeScript are inherently unsound, which isn’t a bad thing, it just means we need to be aware of their limitations.