1 of 21

phantom-types

Arbitrarily narrow types

2 of 21

  • Introduction to problem
  • Solution explained
  • Examples
  • Caveats and issues

3 of 21

Inspiration

  • A tweet by Hillel Wayne along the lines of “just learned about __instancecheck__, I wander what naughty things it can be used for”
  • Blog post “Parse, don’t validate” by Alexis King
  • Links to paper “Ghosts of departed proofs” that formalizes phantom types
  • fthomas/refined, largely inspired the choice to lean into predicate functions and functional composition

4 of 21

5 of 21

6 of 21

7 of 21

8 of 21

9 of 21

What is a phantom type?

A phantom type is a successful runtime assertion encoded as a static type.

A candidate value is checked against a predicate function at runtime and the static type checker is used to track that the check was successful. So we can safely validate at the edge of our programs only!

10 of 21

11 of 21

12 of 21

13 of 21

14 of 21

15 of 21

Some of the shipped types

  • phantom.datetime.TZAware narrows datetime to require tz info.
  • phantom.interval.{Interval, Natural, …} narrows numerics to ranges.
  • phantom.iso3166.CountryCode narrows str to valid country codes.
  • phantom.re.FullMatch narrows str to those matching a pattern.
  • phantom.sized.PhantomSized narrows sized iterables to mandate certain sizes, like NonEmpty.

16 of 21

CountryCode is special …

It’s implemented as a union of a Literal of all valid country codes, and a phantom type for parsing values not known at “compilation time”. This allows mixed statements like this to pass type checking:

17 of 21

Caveats and issues

  • Narrowing on mutable types is dangerous. Shipped types only support narrowing on immutable types.
  • PhantomSized and its subtypes relies on _ProtocolMeta, which isn’t documented.
  • Better support for functional composition would be nice, e.g. n-ary compose().
  • Doesn’t work with functools.singledispatch().

18 of 21

19 of 21

Compatibility

  • pydantic
  • dacite
  • beartype
  • typeguard (the library)

Note that combining phantom types with runtime type checkers like beartype and typeguard is runtime-equivalent to “Design by contract”.

20 of 21

Extra: Some implementation details

  • PhantomMeta proxies calls to isinstance() to a method on the class itself, and prevents instantiation.
  • PhantomBase(metaclass=PhantomMeta) provides abstract method __instancecheck__.
  • Phantom(PhantomBase) provides the class argument predicate, and allows for composing phantom types from predicate functions.
  • phantom.predicates.* provides helpers for composing predicates.
  • phantom.ext.* provides wrappers around third-party validation libraries.
  • A bunch of custom types in phantom.*.

21 of 21

Extra/discussion: Introducing phantom types

When introducing phantom types into an application like in the example, one should start at the ORM level. That will allow the type checker to reveal all places in the business logic layer that needs to be adapted.

After adapting the business logic layer, the type checker should reveal all places in the layer above that needs to be adapted.