Skip to content

Instantly share code, notes, and snippets.

@simonwhitaker
Last active August 17, 2022 04:16
Show Gist options
  • Save simonwhitaker/3a24d669b26b4f373690c6eb113c3ae7 to your computer and use it in GitHub Desktop.
Save simonwhitaker/3a24d669b26b4f373690c6eb113c3ae7 to your computer and use it in GitHub Desktop.

Python: Type Aliases vs New Types

If you're a fan of Python type hinting, you may have noticed that there are two different ways to create your own types: type aliases and using the NewType helper.

Type aliases look like this:

Vector = list[float]

Using the NewType helper looks like this:

from typing import NewType

Vector = NewType("Vector", list[float])

In either case, you now have a type called Vector that you can use in your code.

def min(v: Vector) -> float:
    # TODO: write implementation
    return 0.0

So, which should you use, and when?

Use type aliases to add clarity

Type aliases are exactly that; simply an alias for a type. Anywhere you refer to the original type (e.g. list[float]), you can now also refer to it as the alias (e.g. Vector) instead; they are interchangeable synonyms.

Type aliases are useful for simplifying and clarifying code that deals with complex types.

For example, in 2D geometry software it's common to deal with points in two-dimensional space, represented as a pair of floats:

origin = (0.0, 0.0)

In that example, the type of origin is tuple[float, float]. So to write a function that takes a point as input, you'd write:

def move_to(point: tuple[float, float]) -> None:
    # TODO: write implementation
    pass

Similarly, it's common to define sizes as a width and a height, also represented as a pair of floats:

size = (3.0, 4.0)

And finally, it's common to define a rectangle as a point, defining the origin of the rectangle, and a size:

origin = (0.0, 0.0)
size = (3.0, 4.0)
rect = (origin, size)

Here origin is of type tuple[float, float], and size is also of type tuple[float, float], so rect, which is a tuple containing origin and size, is of type tuple[tuple[float, float], tuple[float, float]]. This starts to become cumbersome. For example, let's look at a function that takes a rectangle as input:

def get_area(rect: tuple[tuple[float, float], tuple[float, float]]) -> float:
    # TODO: write implementation
    return 0.0

Ugh, that's messy! And it gets worse. Let's say we have a function that takes a point and a rectangle, and determines whether the point lies within the rectangle:

def point_is_in_rect(
    point: tuple[float, float], 
    rect: tuple[tuple[float, float], tuple[float, float]]
) -> bool:
    # TODO: write implementation
    return False

Your function definitions start to look less like function definitions and more like type soup. Ugh.

This is exactly the problem that type aliases solve.

Let's see how type aliases can make this code much more readable:

# Declare some type aliases to make sense of the chaos
Point2D = tuple[float, float]
Size2D = tuple[float, float]
Rectangle = tuple[Point2D, Size2D]

def get_area(rect: Rectangle) -> float:
    # TODO: write implementation
    return 0.0

def point_is_in_rect(point: Point2D, rect: Rectangle) -> bool:
    # TODO: write implementation
    return False

rect = ((0.0, 0.0), (3.0, 4.0))
print(get_area(rect))

x = (2.0, 5.0)
print(point_is_in_rect(x, rect))

Much better!

Note that these type aliases are just that: aliases. There's nothing special about Point2D, you can use it anywhere you would use tuple[float, float]. You can even pass a Point2D to a function expecting a Size2D and vice versa, since they're both synonyms for the same type. This may or may not be your intention.

Use the NewType helper to enforce type correctness

Unlike type aliases, the NewType helper creates a completely new type. It is not a synonym, and cannot be used interchangeably with its underlying type.

A good example of where this might be useful is in our code from the previous section. Consider this snippet of code:

Point2D = tuple[float, float]
Size2D = tuple[float, float]
Rectangle = tuple[Point2D, Size2D]

def get_area(rect: Rectangle) -> float:
    _, size = rect
    width, height = size
    return width * height

origin = (0.0, 0.0)
size = (3.0, 4.0)
rect = (size, origin)
print(get_area(rect)) # prints 0.0

There is a semantic error in this code. In the line where I instantiate the rect variable, I've passed the size and origin in the wrong order. The syntax is correct, and the code runs just fine, but the output is not as expected.

In its current form, the type checker cannot help me here, because Point2D and Size2D are exactly the same type. They are both just aliases for tuple[float, float].

Is there a way the type checker could have caught this bug at type-checking time, rather than allowing it to misbehave at runtime?

Yes. This is exactly the problem that NewType solves.

Let's rewrite the code, but this time declare Point2D and Size2D as new types.

from typing import NewType

Point2D = NewType("Point2D", tuple[float, float])
Size2D = NewType("Size2D", tuple[float, float])
Rectangle = tuple[Point2D, Size2D]

def get_area(rect: Rectangle) -> float:
    _, size = rect
    width, height = size
    return width * height

# To instantiate types declared with NewType with 
# literal values, wrap the values in TypeName(...)
origin = Point2D((0.0, 0.0))
size = Size2D((3.0, 4.0))

rect = (size, origin)
print(get_area(rect))

Now let's type-check this code. There are a number of type checkers available for Python; I'm using Microsoft's pyright, which is the default Python type checker in VS Code.

$ pyright demo.py 

  /Users/simon/demo.py:18:16 - error: Argument of type "tuple[Size2D, Point2D]" cannot be assigned to parameter "rect" of type "Rectangle" in function "get_area"
    Tuple entry 1 is incorrect type
      "Size2D" is incompatible with "Point2D" (reportGeneralTypeIssues)
1 error, 0 warnings, 0 informations 

Great! This error is also highlighted for me in VS Code, so I can see the error immediately as I type my code.

Screenshot 2022-07-29 at 11 32 34 am

Should I just use NewType everywhere?

Probably not. NewType adds a small overhead for developers, namely having to wrap literal values in TypeName(...) every time you instantiate a value of a type created with NewType. (There's also a minuscule runtime overhead to using NewType, but you almost certainly don't need to worry about that.)

I like to use these two approaches together, as described above. I use NewType when I need to enforce type correctness, and type aliases where I want to avoid repeating complex types.

@simonwhitaker
Copy link
Author

simonwhitaker commented Jul 29, 2022

NB: being able to use generics with native collection types (e.g. list[float]) was added in Python 3.9. If you’re on Python 3.8 or earlier, then instead of this:

Vector = list[float]
Point = tuple[float, float]

you need to do this:

from typing import List, Tuple

Vector = List[float]
Point = Tuple[float, float]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment