This article focuses on tuples and uses advanced TypeScript features to implement them. Review mapped types, conditional types, generics, type constraints, and inference before reading. Additionally, it's more of an "interesting" walkthrough over the tuple concept and some related musings. Don't take it too seriously.
Ugly Relationship Between Tuples In TypeScript And JavaScript
I dislike creating clickbait article titles, but I couldn't think of a better name. This is because TypeScript is a somewhat unusual technology. Without JavaScript, it's just a fancy linter. What do I mean by that?
TypeScript and JavaScript operate in completely separate worlds. TypeScript runs at compile time when you build your codebase, verifying the applied rules. JavaScript, on the other hand, is executed at runtime when a user is using your application, where real for
loops and if
statements run.
Everything works fine until it doesn't. Both technologies try to cooperate in a way where you create a TypeScript file, add type annotations, and then the TypeScript compiler kicks in. It compiles your code automatically when you change the TS files (due to the --watch
mode) and manually when you trigger the build process of the application.
If everything is fine during build, JavaScript files are created and placed in the typical public
directory. If any problems are detected during compilation, you'll see errors in the terminal.
But there's a peculiar case. TypeScript implements something called a tuple - a data structure that is not available in the JavaScript world. JavaScript knows arrays, which are somewhat similar to tuples, but they are quite different.
Interested? Let's dive into the differences.
Understanding Tuple
Before we begin our discussion and analysis, we need to understand what a tuple is.
A tuple is a data structure similar to an array. Once the structure is defined at the beginning of a program, it cannot be changed later.
Array And Tuple On The Diagram
To make it easier, we can add a typical checklist to indicate the requirements of a tuple:
- Once defined, the structure (length, order, data types under each element) is unchangeable.
- The length is statically known and consistent.
- We can modify each element of the tuple, but only when the type remains the same or block edition totally (depends on use case).
In practice, operations like pop, push, reverse, and sort should be blocked by default. Changing the order, adding, sorting, or removing elements is not allowed in this data structure. Once declared and defined at the beginning of the program, its structure is protected. Let's write some pseudo code and assume we have a Tuple
built into JavaScript.
const tuple = Tuple(1, "hi", [], {});
tuple.sort(); // Blocked π’.
tuple.reverse(); // Blocked π’.
tuple.map((item) => item.toString()); // Allowed π.
tuple.push("mrr"); // Blocked π’.
tuple.pop(); // Blocked π’.
What about changing the value at a specific index, like tuple[0] = 10
? If the new value has the same signature and data type, it should be possible. This can be useful, for instance, when implementing a tic-tac-toe game with a limited number of areas (9), which is a great use case for a tuple!
But if you try to do something like tuple[0] = '1'
, it should not be allowed. This changes the type of the value stored at index 0, and the same rule applies to other indexes.
What about complex data types like objects? Yes, you should be allowed to change the object, but you must replace it entirely with one of exactly the same structure. Take a look at the code below to understand:
const tuple = Tuple(1, Tuple(1), [1, 3], { flag: true });
tuple[0] = 8; // π Allowed.
tuple[0] = '8'; // π’ Blocked. Data type is different.
tuple[1] = [1]; // π’ Blocked, it requires a "Tuple"!
tuple[2] = [1, 4, 5]; // π Allowed (because it's an number[] array not a "tuple")!
tuple[3].flag = false; // π’ Blocked, a complete replacement is required.
tuple[3] = { flag: false }; // π Allowed, complete replacement and same structure.
If you've used tuples built into TypeScript, you might be feeling a bit confused. For those who aren't, let's continue γΎ(β§β½β¦*)o.
Deeply Frozen Tuple
Sometimes, you want to completely block any modification of tuples. This can be useful when you have a structure, like a matrix, that needs to be created once and remain constant thereafter. Once defined, it should never be changed. In the previous heading, I mentioned that tuples should allow you to modify the values but not change the type of those values. That's true.
However, some languages give you the option to choose the behavior. For example, in Swift
, you can allow value changes of the same type or block any kind of modification entirely.
// Immutable (Frozen) Tuple.
let frozenTuple: (Int, String, Double) = (1, "Hello", 3.14)
// Trying to change any value will result in a compile-time error.
// frozenTuple.0 = 2 // Error: Cannot assign to value: 'frozenTuple' is a 'let' constant.
// frozenTuple.1 = "World" // Error: Cannot assign to value: 'frozenTuple' is a 'let' constant.
// frozenTuple.2 = 2.71 // Error: Cannot assign to value: 'frozenTuple' is a 'let' constant.
// Mutable Tuple.
var mutableTuple: (Int, String, Double) = (1, "Hello", 3.14)
// Changing values.
mutableTuple.0 = 2
mutableTuple.1 = "World"
mutableTuple.2 = 2.71
// Printing the updated tuple.
print(mutableTuple) // Output: (2, "World", 2.71)
So, the point about "modifying" elements at certain positions is "switchable." You can choose to allow modifications, but only if the new element is of the same type, or you can block it entirely. This depends on your use case. Here is what this behavior might look like in JavaScript:
const tuple = FrozenTuple(1, Tuple(1), [1, 3], { flag: true });
tuple[0] = 8; // π’ Blocked.
tuple[0] = '8'; // π’ Blocked.
tuple[1] = [1]; // π’ Blocked.
tuple[2] = [1, 4, 5]; // π’ Blocked.
tuple[3].flag = false; // π’ Blocked.
tuple[3] = { flag: false }; // π’ Blocked.
In this JavaScript example, any attempt to modify the elements of the tuple
is blocked, ensuring that the tuple remains deeply frozen. You can just read values, nested values, and nothing more.
Tuple In TypeScript
As mentioned in intro to the article, there is not builded in anythig that is called a "tuple" in JavaScript. However, TypeScript allows to create something like that (it's a tuple that's correct, but only at compile-time). At runtime, it's normal array, and nothing else... Take a look at following code to understand:
let tuple: [string, number, boolean] = ["hello", 42, true];
tuple = []; // π₯ "Must have at least 3 elements"
tuple = ["d", 1, false];
tuple = [1]; // π₯ "Number is not assignable to type string"
tuple[1] = 1; // It works.
tuple[0] = true; // π₯ "Boolean is not assignable to type string"
tuple.pop(); // π Why does this work???
TypeScript protects us from some operations, but the pop
method and other mutable ones can still be triggered. To block these, we need to add a Readonly
descriptor. Without this, something referred to as a tuple by many developers is still not a true tuple and does not match the definition. Once these are applied, it will be a real tuple.
let tuple: Readonly<[string, number, boolean]> = ["hello", 42, true];
We can avoid additional type declarations on the left side, which would be redundant, by using the inference mechanism with the as const
syntax.
Three Elements Tuple
This results in the following behavior for both syntaxes:
Push Method Does Not Exist in Tuple
Damn, it looks like it works. Yes, maybe if JavaScript didn't exist, it would be a perfect solution. But due to the complex connection between JS and TS, it's not so simple...
TypeScript Tuple At Runtime
There is nothing wrong with the previous code; it will be 100% valid once the TypeScript compiler kicks in and transforms it to JavaScript after running the tsc
command. However, the produced runtime code will not be a tuple by definition, because the behavior implemented via fancy syntaxes like as const
works only in the TypeScript world (at compilation time).
Some time ago, there was an issue with enum
. During compilation, TypeScript added some JavaScript boilerplate to provide an enum
implementation at runtime. When developers recognized this, they started complaining about breaking the "magic" wall; TypeScript should not create any additional runtime code. However, you're not forced to use these features, right? And put yourself in the shoes of TypeScript authors - there is no built-in enum
in JavaScript, so how could they provide the same behavior at runtime without adding it themselves?
// TS.
enum Colors {
Red,
Green,
Blue
}
// Produced JS.
"use strict";
var Colors;
(function (Colors) {
Colors[Colors["Red"] = 0] = "Red";
Colors[Colors["Green"] = 1] = "Green";
Colors[Colors["Blue"] = 2] = "Blue";
})(Colors || (Colors = {}));
The pushback from the community was so strong that they decided to maintain backwards compatibility and added const enum
, which does not add any runtime code and works only in TypeScript, just like a TS tuple!
// TS
const enum Colors {
Red,
Green,
Blue
}
// Produced JS.
"use strict";
Go to the TS playground to see how it works.
But it's not about enum
; it's about tuples. So, we have the same situation here. One more time: a TypeScript tuple is a tuple only in TypeScript (at compilation time), but not at runtime. I can do whatever I want with such a tuple:
const reverse = (arr: any[]) => {
return arr.reverse();
};
reverse(tuple); // Good luck...
Type Safety Before We Start
Before we start "improving" tuples to be valid by definition at run/compile time, we need to understand type safety.
Type safety in TypeScript refers to the assurance that operations on variables are performed on values of compatible types, as determined at compile time.
Here is an example of type-safe code. We have checked every element returned from the API before performing a sum
.
// Type guard that iterates through each element and checks if each is a number.
const areNumbers = (maybeNumbers: unknown): maybeNumbers is number[] => {
if (Array.isArray(maybeNumbers)) {
return maybeNumbers.every((num) => typeof num === `number`);
}
return false;
};
// Defines a function to sum any number of numerical arguments.
const sum = (...numbers: number[]): number => {
return numbers.reduce((sum, number) => number + sum, 0);
};
// Loads and processes numbers from an API.
const load = async () => {
const numbers = await getNumbersFromAPI();
if (areNumbers(numbers)) {
// At this point, it's guaranteed that `numbers` contain only real numbers.
sum(...numbers);
}
};
I've searched the internet for a library, polyfill, or any implementation that provides real type-safe tuples. However, the only relevant resource I found is the JavaScript proposal for tuples, which is currently in a sort of "library limbo" state. You can check it out here: native JS tuple and record data structures proposal.
Crafting Type Safe Frozen Tuple
We'll use a reflection technique to achieve the desired implementation.
Thanks to the Object.freeze
method, we can block modifications to any object in JavaScript at runtime. Any attempt to modify the object will result in a runtime exception.
const obj = Object.freeze({ flag: true });
obj.flag = false; // π’ Runtime error.
Here is the full implementation of FrozenTuple
:
// Type-safe check to determine if the value is a non-nullable object.
const isObject = (
obj: unknown,
): obj is Record<string | number | symbol, unknown> => {
return typeof obj === `object` && obj !== null;
};
// If the value is a non-nullable object, it freezes the object and all
// nested properties recursively.
const deepFreeze = <T>(maybeObject: T): T => {
if (!isObject(maybeObject)) return maybeObject;
const propNames = Object.getOwnPropertyNames(maybeObject);
for (const name of propNames) {
const value = maybeObject[name];
if (isObject(value)) {
deepFreeze(value);
}
}
return Object.freeze(maybeObject);
};
// Freezing the "wrapping" object, which is an array, and all nested stuff inside.
const FrozenTuple = <T extends unknown[]>(items: [...T]): Readonly<[...T]> => {
if (!Array.isArray(items)) {
throw Error(`Passed tuple elements must be an array`);
}
return Object.freeze(items.map(deepFreeze)) as Readonly<[...T]>;
};
const tuple = FrozenTuple([1, { flag: true }, []]);
let f = tuple[0];
let s = tuple[1];
let t = tuple[2];
f = `1`; // π’ Blocked at compile/runtime.
s = []; // π’ Blocked at compile/runtime.
t = {}; // π’ Blocked at compile/runtime.
The code above will completely block any modification of the created tuple. It behaves the same at runtime and compile time.
This is just a proof of concept, not production-ready code, FYI.
The important checks are with Array.isArray
and isObject
. We need to ensure the data types are supported at runtime. If they are, we map over each element, check if it's a non-nullable object, and deeply freeze all its properties.
The top-level values are frozen with Object.freeze
to prevent structure changes. Methods like pop
and push
will still exist but won't be callable. Thanks to the Readonly
annotation in TypeScript, these methods won't appear in the IDE.
Noteworthy syntax includes: T extends unknown[]
, items: [...T]
, and Readonly<[...T]>
. This ensures the argument passed to the tuple constructor is an array. Each array element is of type unknown
, forcing runtime checks. The [...T]
syntax creates a TypeScript tuple, remembering the type for each index and the exact length of the array. Finally, Readonly<[...T]>
adds a readonly
annotation, removing modification options and returning the inferred tuple shape.
This is how our implemented mechanism behaves in the IDE:
Not Perfect Implementation Of Frozen Tuple
If you play with it, you'll quickly see it's not perfect. This becomes apparent when you try to modify an array in a tuple, and pop
and push
methods are still visible in the IDE. However, at runtime, it's completely protected from modification. Implementing it in a generic way is quite hard due to some TypeScript limitations with inference. Maybe it's possible for some TypeScript magicians, but I've tried many ways, and the result is still not perfect.
What About Just a Tuple - Not a Frozen One?
This will be much more complicated than the previous implementation. We want to block modifications to the structure but allow modifications to the values. Imagine the recursive function needed for that. You need to scan every property, validate it, and if the shape is different, throw an exception. Then, perform proper TypeScript inference to guarantee it. It's complex, so let's save time and avoid overengineering.
However, I couldn't resist trying. Hereβs a quick attempt (which isn't perfect but partially works like the previous one). Additionally, it required "wrapping" the tuple in an API that hides the methods inside.
If you're interested, you can check the following repository: code from this article.
Summary
The goal of this article was simple: to show that the concept of a tuple in TypeScript is a bit misleading and to highlight the blurry connection between JavaScript and TypeScript.
Now you should understand what a real tuple looks like, its requirements, and the limitations it should have. The TypeScript tuple implementation without JavaScript is a real tuple, but when combined with JS, it is not. If you need a reminder, here are the key points of a tuple data structure:
- The structure cannot be changed.
- The length cannot be changed.
- Values can be changed (optionally), but the type of values cannot.
Additionally, you've learned how to use some advanced TypeScript mechanisms and the difference between compile time and runtime.
What can I say in the end? Is it really needed? Of course not. This looks like huge overengineering for typical application code, but for library code, especially when dealing with complex, generic logic, such a mechanism may be important - you never know what the consumer of your library will do with your code.
When crafting this article, I had a huge feeling that I was wasting time (ο½οΏ£β½οΏ£)ο½, but I learned a lot about TypeScript inference, generics, and other intricate aspects.