TypeScript for Pythonistas

Authored by Allison Kaptur

I’ve been writing typed Python with for many years, but I had never worked with TypeScript before 2021. When I started working in TS, I was surprised by how different its philosophy is from mypy. In this post, I’ll describe several of the biggest surprises I encountered.

You’re likely to enjoy this post if you have some familiarity with optional static type checking in a dynamic language like JavaScript or Python. If you’re new to type checking in dynamically-typed languages — first of all, welcome! — but note that this post might not be the best place to start.

What is a type? Part 1: Extremely specific type inference

When I started adding types to my Python code years ago, I found it fairly intuitive to understand what a type was. For coders familiar with Python, you can get started with a single sentence: think of a type as a Python class. That class might be a user-defined class that you wrote yourself, or a builtin class like or . A function that takes an instance of class should be annotated with the type signature . So getting started with type annotations didn’t feel too hard. [1]

By contrast, TypeScript types weren’t as intuitive. TypeScript types don’t map to classes in JS — perhaps unsurprisingly, because plenty of JavaScript code doesn’t use classes at all.

Effective TypeScript offers a useful definition of a type in TypeScript: “Think of a type as a set of values.” One way to define a set of values is with a class (“every value that is an instance of class X”), but of course you can define a set of values infinitely many other ways too.

One of my first surprises working with TS was that the types as inferred by the type checker are often not a class, nor anything you would recognize as a JavaScript runtime type. Instead, the inferred type is the literal value of the variable.

To be concrete, if you write Python code like this:

then the mypy type checker will infer the type of as . Sounds great to me — yes, is definitely a string.

But if you write JS/TS code like this:

then the TypeScript type checker will infer the type of as . What? isn’t a type — it’s a value!

In the example, TypeScript has inferred that can have exactly one value, (since it’s a ). So the type of — the set of values that could have — is just . A set containing one value feels like a very different kind of type than a class!

After a few months of working with TypeScript, I came to appreciate TS’s tendency towards narrow type inference. In TS, thinking of a type as a set of values makes it a snap to model flags, state indicators, and other small sets of strings. One example that crops up all the time in our Vue code at Pilot:

The type annotation is a much stronger guarantee than saying that is a string. It gives you immediate feedback if you typo the state (as, say, ) or pass a variable that’s not constrained to those three values into your state-management function.

It is possible to accomplish the same narrow type annotation in Python, but you have to do it explicitly — the type checker won’t infer it. [2]

Thinking of types as roughly equivalent to runtime classes — which isn’t a bad starting place in Python — held me back when I started working in TypeScript, where types are often much narrower.

What is a type? Part 2: Structural typing

The first thing I found jarring about TypeScript was how often the types were narrower than I expected. The second thing was how often types were wider than I expected — they contained more values than just the instances of a class.

TypeScript uses structural typing. When I first read about structural and nominative typing, my eyes glazed over at the jargon, but the core idea is pretty simple: structural typing is about the shape of a type, whereas nominative typing is about the name. TypeScript is mostly structural. Mypy is mostly nominative. Let’s look at some examples to make this concrete.

Nominative typing in Mypy

Mypy is dominated by nominative typing. In general, the type of an object is its class: what you’d get from or at runtime. If you have two classes that have the same shape, mypy doesn’t care about that. To be concrete, the following code produces a type error in mypy:

Although class and have the same shape, the type checker treats them as unrelated. I found this intuitive — if I think I have an instance of when I really have an instance of , that’s often a bug in my code.

Structural typing in TS

Consider the equivalent code in TS:

Neither of these calls of is an error in TypeScript. In TS, type checking is all about the shape of an object. Since both of these objects have the same shape, the type checker treats them as interchangeable.

You might note that in the example above, we’re working entirely in type space — the part of our TypeScript code that will be gone at runtime, after the TS has been transpiled into JavaScript. is a bare JavaScript object, not an instance of a runtime class (other than ). Does this example change if and are runtime classes, as in the Python example? No, it doesn’t change at all! Below, both and are classes (which means there are objects in both the type space and the value space, i.e. in both TS and the resulting JS), and there’s still no error from TypeScript.

This is the heart of structural typing: two types that have the same shape are effectively interchangeable.

Even more surprising to me was that TypeScript’s checking of the shape of a type is not limited to the specified keys. If an object has at least the keys and values types that a TS type annotation expects, it passes the type checker, so even the following is not a type error:

As a longtime Python engineer, JavaScript’s permissiveness has always felt strange to me. However, I think it ultimately reflects a difference in the runtime environment: if you’re running a Python app (not distributing a package), you generally understand and control the runtime — which version of Python is running, what hardware it’s running on, etc. By contrast, if you’re the author of a web app, you virtually never control the runtime, since it’s governed by your users’ browsers. It’s reasonable that TypeScript models and builds on the permissiveness of JavaScript.

Structural typing in Mypy

I said above that mypy is mostly a nominative type checker: based on the names and identities of classes. However, it’s also possible to do TypeScript-style structural typing in mypy.

Structural subtyping was introduced in mypy in 2017, and landed in Python as of version 3.8. By explicitly specifying a protocol — what you might think of colloquially as an interface, or an abstract base class — you can explain the intended shape of your type to the Python type checker. To model the TypeScript behavior above with mypy, you could write code like this:

Nominative typing in TypeScript

I also said above that TypeScript is mostly structural. But just like it’s possible to do structural type checking in Mypy, it’s also possible to do more nominative type checking in TypeScript. The primary way to accomplish this is to use a tagged union (or discriminated union), introduced in TypeScript 2.0.

In a tagged union, you explicitly annotate a type definition with some key that has a literal value. Below, we use , but that’s arbitrary — the name of the key doesn’t matter.

Note that any object that satisfies the shape of (including the tag, so with a of ) will pass the type checker. The following is not a type error[3]:

In summary

Other goodies: Mapped types and type manipulation

In the previous examples, the behavior in TypeScript was expressible in Mypy and vice versa. However, TypeScript also packs a number of concepts that can’t be easily expressed in mypy.

After a few months working with it, I’m really starting to enjoy some of these bells and whistles. Type manipulation like , , , , mapped types, conditional types, and the truly wild template literal types open up a rich language of type expression.

To pick just one example, in some of our test helpers, we want a function that returns a particular type, supplies defaults for all fields, and allows the caller to override some, none, or all of the fields. In TypeScript that annotation is trivial to write using , which constructs a type identical to the input type but with all the properties made optional. In a test, the caller can override some of the fields or none of them, without losing type coverage.

Learning TypeScript for Pythonistas

If you’ve been working with types in Python for a long time, I recommend spending some time exploring TypeScript. I found that the difference in philosophy deepened my understanding of Python types, and clarified the difference between nominative and structural typing in mypy, which is capable of both.

I found two resources helpful for learning TypeScript:

  1. Effective TypeScript. This book is another winner from the Effective series. Its primary benefit for me was providing the language to ask a useful question, and without that, it’s very hard to make progress. For example, it’s much easier to search “user-defined type guard” than to try to figure out the meaning of , or to search “type assertion” or “const assertion” instead of .
  2. The TypeScript playground. The TypeScript playground lets you write code snippets and typecheck them, as you might expect. But what I really love about it is its clear presentation of how that code snippet behaves in the type space and in the value space. I found it very useful to hone my intuition about runtime behavior by checking whether a given operation did anything in the value space.

I wish I’d found these two resources earlier. If you’re ramping up on TypeScript, I recommend them highly.

Of course we’re hiring

It wouldn’t be right to end a company blog post without noting that we’re hiring. If you want to come nerd out about types at Pilot, check out our jobs page or drop me a line!

Footnotes

[1] I did struggle initially with some aspects of type theory that aren’t made explicit in untyped Python code, like generics, unions, and type variables.

[2] Historically, if you wanted to type-check that a state flag in Python had only the three values “loading”, “loaded”, or “error”, the only way to do it was to create an with those three values. This gives us the ability to annotate a type as one of a small set of values, but it accomplishes it by making a class covering only those values — bringing us back to the idea that a type is effectively a class.

As of 2019, you can also add the type annotation in Python. Now you can write Python code that’s closer to the TypeScript version:

I haven’t seen widespread adoption of in Python so far. This is doubtless partially because it’s a new concept. But in my opinion, another hurdle that breaks the Python/mypy heuristic that a type is basically a class. If you’re not used to thinking of types as sets of values, doesn’t make much sense.

[3] is necessary in the object definition so that TS understands to be a literal, rather than inferring it as a string. A key with a string value doesn’t match the type — it has to be a literal.

The engineering, product, and design behind Pilot. Pilot powers the financial back office for startups and small businesses. Learn more at pilot.com