How to Type a Complex JavaScript Array with Typescript

How to Type a Complex JavaScript Array with Typescript

Lessons from Effective Typescript - Prefer Incomplete Types to Inaccurate Types

... it’s interesting that you can express something like “an array of even length” using a TypeScript interface.

Vanderkam, Dan. Effective TypeScript . O'Reilly Media. Kindle Edition.

Intro

I want to start by saying I think it's really cool how JavaScript arrays can contain elements of different types. And it's even cooler that Dan has a plausible example of when we would want to use this feature and how to type such an array if we do use this feature. So let's dig in.

The example

Say we have a Lisp-like language defined in JSON

12
"red"
["+", 1, 2]  // 3
["/", 20, 2]  // 10
["case", [">", 20, 10], "red", "blue"]  // "red"
["rgb", 255, 0, 127]  // "#FF007F"

In this example, each array represents a function call, where the first element is the name of the function, e.g +, and the following elements are parameters to the function.

Our goal is to write type declarations for these inputs to make sure the inputs conform to a well-defined specification.

For motivation, note that the Mapbox library[1] uses a system like this to determine the appearance of map features across many devices, so it's likely we'll come across similar libraries such as Mapbox whose JSON inputs we'll need to type.

For reference, this example is gotten from item 34 of the book, Effective TypeScript.

The process

Quite similar to regular development, Dan suggests adopting a test driven process, and I suggest reading the chapter to see the evolution of passing the test cases, but I want to focus on just the step that caught my interest.

Define test cases

Dan kicks off the process with these test cases, noting the expected outcomes in comments. As the process evolves, we check the output our typescript checker gives us against our expected outcomes.

const tests: Expression2[] = [
  10,
  "red",
  true, // true is not assignable
  ["+", 10, 5],
  ["case", [">", 20, 10], "red", "blue", "green"],  // Too many values
  ["**", 2, 31],  // Should be an error: no "**" function
  ["rgb", 255, 128, 64],
  ["rgb", 255, 0, 127, 0]  // Too many values
];

Vanderkam, Dan. Effective TypeScript . O'Reilly Media. Kindle Edition.

At step 3 of the process we're able to allow strings, numbers, and arrays starting with known function names, with the following type definitions.

type FnName = '+' | '-' | '*' | '/' | '>' | '<' | 'case' | 'rgb';
type CallExpression = [FnName, ...any[]];
type Expression3 = number | string | CallExpression;

const tests: Expression3[] = [
  10,
  "red",
  true,
// ~~~ Type 'true' is not assignable to type 'Expression3'
["+", 10, 5],
  ["case", [">", 20, 10], "red", "blue", "green"],
  ["**", 2, 31],
// ~~~~~~~~~~~ Type '"**"' is not assignable to type 'FnName'
  ["rgb", 255, 128, 64],
  ["rgb", 255, 0, 127, 0]  // Too many values
];

But the next step is the most interesting to me:

What if you want to make sure that each function gets the correct number of arguments?

Progressively define more accurate types

Here's the solution with progressively more accurate types, with my inline explanations.

type Expression4 = number | string | CallExpression;
// Read this as takes a number OR string OR CallExpression

type CallExpression = MathCall | CaseCall | RGBCall;


type MathCall = [
  '+' | '-' | '/' | '*' | '>' | '<',
  Expression4,
  Expression4,
];
/**
This expects an array of length 3, where the first element is

one of the characters in the union, and subsequent elements

are of type Expression4, which can be a CallExpression. 

Given CallExpression can also be a MathCall,

this makes this a recursive type,

where MathCall can point back to refer to itself.
**/

interface CaseCall {
  0: 'case';
  1: Expression4;
  2: Expression4;
  3: Expression4;
  4?: Expression4;
  5?: Expression4;
  // etc.
  length: 4 | 6 | 8 | 10 | 12 | 14 | 16; // etc.
}
/**
This expects an array of length 4 OR 6 OR 8 OR 10  e.t.c,

where the first element is 'case', and subsequent elements

are of type Expression4.
**/

type RGBCall = ['rgb', Expression4, Expression4, Expression4];

const tests: Expression4[] = [
  10,
  "red",
  true,
// ~~~ Type 'true' is not assignable to type 'Expression4'
  ["+", 10, 5],
  ["case", [">", 20, 10], "red", "blue", "green"],
// ~~~~~~                                ~~~~~~~
// Type '"case"' is not assignable to type '"rgb"'.
//          Type 'string' is not assignable to type 'undefined'.
  ["**", 2, 31],
// ~~~~ Type '"**"' is not assignable to type '"+" | "-" | "/" | ...
  ["rgb", 255, 128, 64],
  ["rgb", 255, 0, 127, 0]
  //                   ~ Type 'number' is not assignable to type 'undefined'.
];

What I found interesting here is that using recursive types, we can express the idea of a function calling other functions, e.g an addition function call, calling a subtraction operation ["+",["-", 5, 1],4].

And combined with the use of an interface, in this instance, CaseCall, we can express the idea of "an array of even length," using the length property.

I found it a brilliant use of simple operations to "almost" effectively model the complex array types we should expect.

I say almost because, even though the test cases come back positive against our expectations, the difficult to read error messages such as:

// Type '"case"' is not assignable to type '"rgb"'.

suggest that perhaps we should prefer a less accurate type than we've strived for. Dan gives more reasons for this preference in the chapter so again I suggest reading it.

For the record

["case", [">", 20, 10], "red", "blue", "green"],
// ~~~~~~                                ~~~~~~~
// Type '"case"' is not assignable to type '"rgb"'.
//          Type 'string' is not assignable to type 'undefined'.

comes about because this is an array of length 5, and so it cannot be of type CaseCall which only expects even lengths. Therefore the type checker assumes it's of type RGBCall.

Interestingly, when I try to define RGBCall as length 4, the type checker then assumes it's a MathCall

image.png

And if I continue along that path and fix MathCall to a length of 3, it concludes the expression cannot be a CallExpression, showing how powerful the length specification is when typing an array.

image.png

Conclusion: Prefer incomplete types to inaccurate types

The item containing this lesson concludes that we should avoid such complex types if they result in confusing error messages, and I think it's a really good point to note if you agree with Dan when he says the TypeScript's language services are as much a part of the TypeScript experience as type checking.

Dan also leaves us with the following re: the types from above.

The complexity of this type declaration has also increased the odds that a bug will creep in. For example, Expression4 requires that all math operators take two parameters, but the Mapbox expression spec says that + and * can take more. Also, - can take a single parameter, in which case it negates its input. Expression4 incorrectly flags errors in all of these:

const okExpressions: Expression4[] = [
  ['-', 12],
// ~~~ Type '"-"' is not assignable to type '"rgb"'.
  ['+', 1, 2, 3],
// ~~~ Type '"+"' is not assignable to type '"rgb"'.
  ['*', 2, 3, 4],
// ~~~ Type '"*"' is not assignable to type '"rgb"'.
];

Happy typing 🥳

Links

[1] - Mapbox Library