Article summary
Before I started at Atomic, I wrote a lot of Python. This was back in the day when its primary competition was Perl or PHP. Python 1.5.2 was once the thing; Python 2 was kind of scary and new for a bit.
When Python 3 came along, it didn’t feel like a huge shift. The biggest change in my book was relabeling “str” to mean Unicode characters instead of bytes, which broke all kinds of stuff. But you still programmed it mostly the same way, just with more modern APIs.
I haven’t done nearly as much Python in the intervening years. But the rise of LLMs and similar technologies, as well as some recent work for a client, have got me looking at it again. And I’ve been kind of astonished and giddy at the change that’s been going on.
The “typing” module has been around in Python 3 for a little bit now; since 3.5, actually. At the time it launched, I did look at it briefly. It seemed to be primarily for annotating variables and functions a little bit. These annotations then got read by tools, which in turn could flag code that looked like it was violating them.
I didn’t find much use for them at the time. It definitely suffered from barely anyone using it, making it less useful. And it didn’t change the language at all; Python was still dynamically, if strongly typed.
While I was off working with TypeScript, though, Python projects continued to adopt “typing” more and more, and annotating their own code. When 3.11 launched, in particular, a lot of cool new stuff landed, making types much more useful.
I am busy right now integrating this stuff into some recent Python projects of mine, and finding it very useful.
Class Syntax for Creating Types
Named tuples have been around for awhile. They are, in short, “tuple-like objects that have fields accessible by attribute lookup”, in addition to all the other ways you can use tuples.
But making them has always been kind of weird and awkward.
>>> Point = namedtuple('Point', ['x', 'y'], defaults=[0.0, 0.0])
>>> Point(x=1.0)
Point(x=1.0, y=0.0)
They can definitely be useful, especially as they give hints as to what each of their items are. But they just don’t feel as nice as constructing anonymous tuples ((1, 0)
) or just going ahead and writing a class. Of course, classes mean writing a constructor, and whatever other code you need, all to just group a couple things together… ugh.
Turns out, these days, you’ve got a much better option.
class Point(NamedTuple):
"""Coordinates of a point on a graph."""
x: float = 0.0
"""Horizontal coordinate."""
y: float = 0.0
"""Vertical coordinate."""
Even without the docstrings (which used to be something we could add, but it was difficult; take a look at the “Python 2” section), this is already substantially better. The type annotations for “x” and “y” give tools the information they need to properly type those items. And the syntax is way nicer!
And we also have something else that leverages this syntax: data classes.
@dataclass
class Point:
x: float
y: float
While I’ve removed the docstrings here for brevity, they’re still available, of course. But a data class is a great way to build an object with mutable properties, building the constructor for you as well as all the other bits and pieces you’d otherwise need to make yourself.
Protocols
class WithCoordinates(Protocol):
x: float
y: float
@dataclass
class LocatableItem:
x: float
y: float
name: str
def where_is(wc: WithCoordinates):
return f"at {wc.x},{wc.y}"
item = LocatableItem(x=2.0, y=-4.0, name="a big dot"
print f"{item.name}: {where_is(item)}"
Without making LocatableItem inherit from WithCoordinates, we satisfy the type checker when we call “where_is” here by using a Protocol.
This may seem a bit contrived, but I’ve found this pattern is incredibly useful in (for example) TypeScript, when using objects that have certain properties, but aren’t part of a class hierarchy I can control.
There’s also an annotation that enables a lightweight Protocol check at runtime, checking for just the presence of its properties. Just use “isinstance”.
More Highlights
Those are the highlights I’ve found, but there’s more.
- Generics are a thing, and syntactically very easy to use since Python 3.12, making all kinds of types that much more useful.
- NewType, which creates “distinct types”, for when you want to pass around an “int” at runtime but give it a special type when the type checker is running.
- TypedDict, which uses the same class syntax we looked at above to type-annotate a dict. Very useful when dealing with objects from JSON.
- The ability to look at type hints at runtime. They’re not normally checked at that stage, but you can look into the hints and solve some special cases this way.
- And much, much more.
Getting types checked while you’re developing is pretty easy thanks to pyright and (if you’re using Visual Studio Code) Pylance. Pylance is part of Microsoft’s standard Python support for Code, so you may already be using it!
But what’s especially cool is how typing has transformed the language as well as runtime capabilities. I think it makes Python quite a bit easier to use (and use correctly!) as well as more capable. People are building lots of great stuff on top of types, too. That’s good news, as Python is becoming more and more important.
And frankly, it just makes me happy, too, seeing that the first language I really used professionally has evolved so much over the years.