How TypeScript breaks referential transparency

January 23, 2019 0 Comments

How TypeScript breaks referential transparency



Have you ever factored out a constant in TypeScript, only to have the type checker start reporting some mysterious errors? There’s a fundamental reason for this. In this post, I’ll show a few examples of the problem, explain why it happens and present a few possible workarounds.

Suppose we have a function, purchase, which buys a product for us:

We’ll call this “inline form.” In JavaScript, you can factor the inline parameter out into a variable and pass a reference without changing the behavior of your code:

This is sometimes called referential transparency or the Substitution Principle. It refers to the idea that you can replace an expression with its value without changing behavior. This goes hand-in-hand with code being free of side effects and facilitates reasoning about program correctness.

So long as you don’t change the order of evaluation, factoring out a variable this way in JavaScript should not change the result of an expression.

Factoring out a variable works in JavaScript, but what about TypeScript?

So far so good!

Now let’s make it a little more complicated by pretending we’re working with a map visualization which lets us programmatically pan the map:

If you run this in the TypeScript playground, you’ll get an error:

The relevant part is this:

Argument of type ‘number[]’ is not assignable to parameter of type '[number, number]'.

You can mouse over the loc symbol in the TypeScript playground to get a clearer sense of what’s going on:

TypeScript has inferred the type of loc as number[], i.e. an array of numbers with an unknown length. When this is passed to panTo, TypeScript checks whether number[] (an array of numbers) is assignable to [number, number] (a pair of numbers). And it isn’t because some arrays don’t have two elements. Hence the error.

Why does TypeScript infer the type as number[] instead of [number, number]? Because this is valid JavaScript:

In other words, const in JavaScript is not deep. You can’t reassign loc but you can modify it. Some languages (e.g. C++) let you model the precise depth of const-ness, but JavaScript and TypeScript do not.

So how do you fix this? An easy way is to throw any types into your code, to effectively silence the warning:

If you’re going to do this, the latter form is preferable since the any is scoped to the function call and won’t prevent type-checking other uses of loc elsewhere in your code.

A better solution is to tell TypeScript precisely what you mean:

This is a bit verbose, but it’s the best solution in terms of type safety.

The previous example showed that referential transparency can break with tuple types. But that’s not the only situation in which this can come up.

Let’s revisit the purchase example from the start of the post. Let’s refine the type of products that you’re allowed to purchase using a union of string literal types:

Purchasing inline expressions and purchasing by reference both work:

If we use let instead of const, however, TypeScript complains:

The error is that Argument of type ‘string’ is not assignable to parameter of type ‘Product’. Using let tells TypeScript that we want to allow newToy to be reassigned. TypeScript doesn’t know the domain of possible values that we want to allow for newToy, so it guesses string. When we use const, it cannot be reassigned and so TypeScript can infer the narrower string literal type "Macbook Air".

As before, the solution is to tell TypeScript what we mean:

Because const is shallow, it’s not a panacea for this sort of issue. It can still arise when types are inferred for properties in an object. For example:

As before, the error is that 'string' is not assignable to type 'Product'. This is because what’s type is inferred as string to allow this sort of pattern:

As before, the solution is to tell TypeScript what you mean. One way is with a typecast:

Another is by defining an Order interface which specifies the precise types of its properties:

If you get an error like this involving a call to a third-party library, your best bet is to find the relevant type in its type declarations. Let’s look at a real-world example of that.

Here’s some React code which renders a Mapbox GL map using the react-mapbox-gl wrapper:

If we factor the initial viewport out into a constant, we get a long error:

The relevant bit is this:

Types of property 'center' are incompatible.
Type 'number[]' is missing the following properties from type '[number, number]': 0, 1

The center prop is expected to be [number, number] (a pair of numbers), but TypeScript has inferred the type of as number[] (an array). So this is really the same problem as before! And as before, the best solution is to specify a type for INITVIEW.

But if you look through the typings for react-mapbox-gl, the closest thing is the Props structure, which contains all the properties in INITVIEW and then some:

So we can’t just write const INITVIEW: Props to fix the problem because our constant doesn’t have the required style property. So we need to adapt the type. The simplest way is to use Partial, which makes all the properties in a type optional:

The Partial trick works so long as all the properties you want to factor out are optional. If we also wanted to factor out style (which is required), then we’d get an error: Property 'style' is optional in type …. By using Partial, we’ve told TypeScript to forget that we’ve explicitly set style.

One solution is to use Pick to pull out just the properties of interest along with their types. Unlike Partial, this will track which properties we’ve set and which we haven’t:

It’s unfortunate that we have to repeat the keys in the type. Fortunately, you can use this one weird trick from StackOverflow to define a helper to infer the keys for you:

The wrapper is unfortunate but the lack of repetition makes this a better solution. Any minifier should be able to remove the function call.

If you have a few possibilities for the constant (ala StyleSheet.create in React Native) then the situation is a little trickier. You could try an index type:

const views: {[viewName: string]: Partial<Props>} = {
nyc: …,
sf: …

This will work, but it would prevent TypeScript from flagging invalid references like You can solve this with a similar trick to the previous one:

When you factor out a constant and run into a confusing TypeScript error, you may be running into an issue around type inference and referential transparency. As we’ve seen in this post, the solution is usually to find or create a type that you can use in the assignment. Sometimes this is easy and sometimes it is not.

This is a pain point in learning TypeScript because it produces complex errors involving advanced elements of TypeScript’s type system for a simple, valid code transformation. The TypeScript team has a history of coming up with clever solutions to problems like these and they’re actively discussing this one. So stay tuned!

LogRocket is a frontend logging tool that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single page apps.

Try it for free.

Tag cloud