Dan Vanderkam Software Developer working @sidewalklabs

How TypeScript breaks referential transparency

6 min read 1800

(and what to do about it)


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.


Referential transparency in JavaScript

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

function purchase(product) { /* ... */ }
purchase('Macbook Air');

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:

const product = 'Macbook Air';
purchase(product);  // reference form

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.

Referential transparency in TypeScript

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

export function purchase(what: string) { /* ... */ }

purchase('Macbook Air');  // Inline

const product = 'Macbook Air';
purchase(product);  // reference

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:

// Parameter is a (latitude, longitude) pair.
function panTo(where: [number, number]) { /* ... */ }

// Inline:
panTo([10, 20]);  // inline: ok

// Reference:
const loc = [10, 20];
panTo(loc);  // reference. Will this work?

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:

const loc = [10, 20];
loc.push(30);

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:

const loc: any = [10, 20];
panTo(loc);
const loc = [10, 20];
panTo(loc as any);

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:

const loc: [number, number] = [10, 20];
panTo(loc);

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

Inference and string types

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:

type Product = 'iPad' | 'Mac Mini' | 'Macbook Air' | 'Macbook Pro';
export function purchase(what: Product) {
 // mine bitcoin…
 // send to apple…
}

Purchasing inline expressions and purchasing by reference both work:

purchase('Macbook Air');
const newToy = 'Macbook Air';
purchase(newToy);  // ok

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".

Sick of debugging web apps? Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket pairs session replay with technical telemetry to quickly understand what went wrong.

Get a Free Trial of LogRocket

or

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

let newToy: Product = 'Macbook Air';
purchase(newToy); // ok

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:

const order = {
  what: 'Macbook Air',
  how: 'bitcoin'
};
order.what = 'Lenovo'; // valid

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

const order = {
  what: 'Macbook Air' as Product, // or "as 'MacbookAir'"
  how: 'bitcoin'
};
purchase(order.what); // ok

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

interface Order {
  what: Product;
  how: 'bitcoin' | 'usd';
}
const order: Order = {
  what: 'Macbook Air',
  how: 'bitcoin'
};
purchase(order.what); // ok

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.

Inference and React

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

import * as React from 'react';
import ReactMapboxGl from 'react-mapbox-gl';

const Map = ReactMapboxGl({
  accessToken: '....',
});

export function render(): JSX.Element {
  return (
    <Map
      style="mapbox://styles/mapbox/streets-v9"
      center={[-73.991284, 40.741263]}
      zoom={[14.5]}
      pitch={[45]}
      bearing={[-17.6]}
    >
        { /* ... */ }
    </Map>
  );
}

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 INIT_VIEW.center 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 INIT_VIEW.

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

export interface Props {
  style: string | MapboxGl.Style;
  center?: [number, number];
  zoom?: [number];
  maxBounds?: MapboxGl.LngLatBounds | FitBounds;
  fitBounds?: FitBounds;
  fitBoundsOptions?: FitBoundsOptions;
  bearing?: [number];
  pitch?: [number];
  containerStyle?: React.CSSProperties;
  className?: string;
  movingMethod?: 'jumpTo' | 'easeTo' | 'flyTo';
  animationOptions?: Partial<AnimationOptions>;
  flyToOptions?: Partial<FlyToOptions>;
  children?: JSX.Element | JSX.Element[] | Array<JSX.Element | undefined>;
}

So we can’t just write const INIT_VIEW: 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:

const INIT_VIEW: Partial<Props> = {
  center: [-73.991284, 40.741263],
  zoom: [14.5],
  pitch: [45],
  bearing: [-17.6],
};

export function render(): JSX.Element {
  return (
    <Map
      style="mapbox://styles/mapbox/streets-v9"
      {...INIT_VIEW}  // ok
    >
        { /* ... */ }
    </Map>
  );
}

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:

const INIT_VIEW: Pick<MapProps, 'center'|'zoom'|'pitch'|'bearing'|'style'> = {
  center: [-73.991284, 40.741263],
  zoom: [14.5],
  pitch: [45],
  bearing: [-17.6],
  style: "mapbox://styles/mapbox/streets-v9"
};

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:

const inferPick = <V extends {}>() => <K extends keyof V>(x: Pick<V, K>): Pick<V, K> => x;

const INIT_VIEW = inferPick<MapProps>()({
  center: [-73.991284, 40.741263],
  zoom: [14.5],
  pitch: [45],
  bearing: [-17.6],
  style: "mapbox://styles/mapbox/streets-v9"
});

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 views.london. You can solve this with a similar trick to the previous one:

const inferKeys = <V extends {}>() => <K extends string>(x: Record<K,V>): Record<K,V> => x;

const INIT_VIEW = inferKeys<Partial<MapProps>>()({
  nyc: {
    center: [-73.991284, 40.741263],
    zoom: [14.5],
    pitch: [45],
    bearing: [-17.6],
  },
  sf: {
    center: [-73.991284, 40.741263],
    zoom: [14.5],
    pitch: [45],
    bearing: [-17.6],
  }
});

export function render(): JSX.Element {
  return (
    <Map
      style="mapbox://styles/mapbox/streets-v9"
      {...INIT_VIEW.nyc}  // ok
    >
        { /* ... */ }
    </Map>
  );
}

Conclusions

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!

Plug: LogRocket, a DVR for web apps

https://logrocket.com/signup/

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.

Dan Vanderkam Software Developer working @sidewalklabs

Leave a Reply