Tuples are ok
Pragmatic Maciej

Pragmatic Maciej @macsikora

About: I am Software Developer, currently interested in static type languages (TypeScript, Elm, ReScript) mostly in the frontend land, but working actively in Python also. I am available for mentoring.

Location:
Lublin
Joined:
Mar 25, 2019

Tuples are ok

Publish Date: Mar 28 '20
45 13

There are opinions in the community that Tuple types should not be used ever. There are movements also against functions arguments, and using one dictionary/map argument instead. As with all radical opinions, saying that we should not use tuples is wrong. We should, but not for everything, in the same way there is no ideal data structure, tuple has limited correct scope of use.

What is Tuple

Tuple type represents ordered list of fixed size, and fixed type of elements. The most common tuple is a pair, so tuple with 2 elements. For example we can represent a point by a pair [number, number] in TS notation.

Functions arguments list is a tuple

If you have a function, its arguments form a tuple, so for example lets consider simple move function which will move the point.

// [TS]
declare function move(x: number, y: number): void
move(1,2);
// equivalent to
declare function move(...[x, y]: [number, number]): void
move(1,2)
Enter fullscreen mode Exit fullscreen mode

Tuple is isomorphic to Map

Tuple and Map/Dictionary are example of Product types and are isomorphic. Isomorphism means that from every tuple we can make a map, and from every map we can make a tuple. The proof is simple transformation in boths direction.

// [TS]
type TuplePoint = [number, number];
type MapPoint = {x: number, y: number};
// below transformations in both directions
function toTuple({x,y}: MapPoint) {
  return [x,y]
}
function toMap([x,y]: TuplePoint) {
  return {x, y}
}
Enter fullscreen mode Exit fullscreen mode

When to use Tuple

Tuples are great if small. It means that there is no issue in using double or triple tuples. The question starts at quadruple, for me it is a moment when it can work well, but also it can start to be a problem. But to be clear, I believe there can exists great use for longer tuples, however I would be careful with such.

Good examples of using tuples are points, dimensions like (x,y), (width, height), (x,y,z), also almost all pairs, like (name, lastname), (symbol, translation) and so on. Because of destructuring (destructuring exist in most languages with tuples - JS/TS, Python, Elm, Reason, Haskell) and possibility of naming elements of the tuple, there is also no issue in readability. Consider comparison function taking two arguments (a pair), and function taking one labelled argument.

// [TS]
function fullName(name, lastName) {
  return name.concat(lastName);
}
fullName("John", "Doe");
// in contrary version with map
function fullName({name, lastName}) {
  return name.concat(lastName);
}
fullName({name: "John", lastName: "Doe"}) // more boilerplate
Enter fullscreen mode Exit fullscreen mode

React useState as a great usage of the Tuple type

React hook useState is returning a tuple. The reason why tuple is the best choice here is the polymorphism of useState. We really use the same function to represent different states, so also the naming should be different. JS destructuring feature allows for local aliasing tuple structures.

// [JS]
const [name, setName] = useState("");
const [lastname, setLastName] = useState("");
const [age, setAge] = useState(0);
Enter fullscreen mode Exit fullscreen mode

In contrary how it would look if React team would use map instead:

// [JS]
const {value: name, setValue: setName} = useState("");
const {value: lastName, setValue: setLastName} = useState("");
const {value: age, setValue: setAge} = useState(0);
Enter fullscreen mode Exit fullscreen mode

Better? Don't think so 😉.

To be fair we could be doing with map const nameState = useState(""); nameState.setValue("Tom"); What is kinda ok.

When to not use tuples

As said before, tuples are great when small. Long tuples can be a pain, the biggest reason is that with longer tuple its harder to remember at which position stands which thing, we can fix that in TypeScript by aliasing types, but this is an additional thing to do. So I would rather think twice before using longer tuple.

The bad using of the tuple

// [TS]
type User = [string, string, number, bool]; // yhym, so what is second string?
// we can fix that by aliasing
type Name = string;
type Lastname = string;
type Age = string;
type Active = boolean;
type LittleBetterUser = [Name, LastName, Age, Active] // yhym now I get it
// but map will work best here
type UserAsMap = {
  name: string,
  lastname: string,
  age: number,
  active: boolean
}
Enter fullscreen mode Exit fullscreen mode

BTW, do you remember that functions with many arguments are consider as a bad practice? As we already said function arguments list is a tuple, and using long tuple can be a burden, in the same way functions with many arguments can be considered as a problem.

Summary

Tuple is a very nice structure, used wisely should be considered as alternative for small structs/maps. Don't be radical and give Tuple some love ❤.

Comments 13 total

  • Amin
    AminMar 28, 2020

    Too bad JavaScript does not have a real Tuple system compared to Elm.

    numbersToPair : Int -> Int -> ( Int, Int )
    numbersToPair number1 number2 =
        ( number1, number2 )
    
    
    doSomething : Int -> Int -> ( Int, Int, Int )
    doSomething number1 number2 =
        let
            ( first, second, third ) = numbersToPair 1 2
    
        in
            ( first, second, third ) 
    
    Enter fullscreen mode Exit fullscreen mode

    This code will produce

    This definition is causing issues:
    
    13|>    let
    14|>        ( first, second, third ) = numbersToPair 1 2
    15|>
    16|>    in
    17|>        ( first, second, third ) 
    
    This `numbersToPair` call produces:
    
        ( Int, Int )
    
    But then trying to destructure it as:
    
        ( a, b, c )
    
    Enter fullscreen mode Exit fullscreen mode

    While this code in JavaScript

    "use strict";
    
    const numbersToPair = (number1, number2) {
        return [number1, number2];
    }
    
    const doSomething = (number1, number2) {
        const [first, second, third] = numbersToPair(number1, number2);
    
        return [first, second, third];
    }
    
    Enter fullscreen mode Exit fullscreen mode

    Will work, and third will get undefined. I guess JavaScript is gone on a path where Tuple is something that will never see the light anytime soon. And I'm really sad to see that.

    • Pragmatic Maciej
      Pragmatic MaciejMar 28, 2020

      I don't think it is fair comparison. JS has no compiler so such construct you have put here has no place to be verified, and because of weak typing JS even don't throw here runtime error.

      But its true that in JS there is nothing like tuple at the language level, its just an Array with fixed length, nothing more. So it is effected by all array methods like push or pop which never should be in tuple type.

      We can though compare Elm with TypeScript, and here if you will type it correctly the error also will be there. Consider:

      const numbersToPair = (number1: number, number2: number): [number, number] => {
          return [number1, number2];
      }
      const doSomething = (number1: number, number2: number) => {
          const [first, second, third] = numbersToPair(number1, number2); // error
          return [first, second, third];
      }
      
      Enter fullscreen mode Exit fullscreen mode

      The Playground

      TypeScript correctly shows an error here 👌.

      BTW. I am also fan of Elm. So thanks for the comment!

      • Amin
        AminMar 28, 2020

        I didn't know that we could use that Tuple-like syntax in TypeScript. thanks for showing. There are places where it can be very useful.

        But since TypeScript will eventually transpile to JavaScript (and since we can't force users of our libraries to use TypeScript) there will always be a flaw in that system, where in Elm you have to use Elm.

        Hence why I compared Elm and JavaScript because TypeScript is in the end a superset, not a language (unfortunately).

        • Pragmatic Maciej
          Pragmatic MaciejMar 28, 2020

          TypeScript is a language. It has no run-time representation, types are removed, but you cannot say its not an language only because it a superset of another language. You can also have run-time data decoders and validators via Elm by using TS abstraction, at the end of the day both languages land into JS.

          • Amin
            AminMar 28, 2020

            Yes you are correct. I was wrong for saying that this is not a language.

            My point was to say that you can have interfaces, tuples, and everything you want, this won't prevent me from using your library, compile it to javascript and use the wrong types because in the runtime, it is JavaScript.

            When with Elm, even when you are using ports you get type safety because even if Elm does compile to javascript, you are in the sandbox of the Elm runtime, which keeps you safe in terms of type checking. And if you dig in the documentation, you'll see that all side effects are handled by the Elm runtime so that you never have to leave the runtime for anything (except again using ports).

            • David Trapp
              David TrappMar 29, 2020

              Well you can also link an assembly program to a C++ library and call a function of the library (by its mangled name) with all the wrong calling convention and argument types and have the library code crash on the call. Yet C++ is a language...

              • Amin
                AminMar 29, 2020

                Are you comparing C++ to TypeScript? I'm not sure I get the point.

                As far as I can remember, C++ is not a superset of Assembly. Please, elaborate your point.

                • David Trapp
                  David TrappMar 29, 2020

                  C++ compiles to assembly (or machine code directly, which can be viewed as the same thing in different representation), just like TypeScript compiles to JavaScript (or a subset of it to Web Assembly - see the link at the end of my comment). My point is that in any language it's possible to escape from the "world" it builds by having something that lives outside of that world making it do unwanted things, circumventing any "in-world" protections this way. Just like I can defeat TypeScript's type safety by interacting with it from JavaScript, I can defeat C++'s type safety by interacting with it from assembly (or actually any other language that allows me to interact with a library - again analogous how I could use, say, CoffeeScript to "attack" TypeScript the same way I can use JavaScript for that).

                  In both cases I just think it's not right to call the higher-level language less of a language than the lower-level one it compiles to just because there are ways outside of the language's defined borders to make some features of it work not as expected, because then basically every language is actually not a language except maybe for the microcode implementation of opcodes inside the processor, and even then I could mess with it on a hardware level.

                  And for the above point it doesn't matter that the syntax of TypeScript is mostly (not entirely, by the way!) a superset of JavaScript. It is still its own language which just happens to borrow 99% of JavaScript's grammar, from that perspective. Crystal has also an extremely high similarity with Ruby and is still a different language entirely, which is clearer to see there since Crystal does in fact not compile to Ruby (or take C# and C++, or Visual Basic and QBasic, etc.), but the concept that similarity doesn't devalue either of the similar entities should apply in both cases.

                  You can also look at it the other way: I guess you would agree more that Elm is a separate language. Yet it also compiles to JavaScript and you can also trip it over by forcing it to do unintended things from the JavaScript side. The difference is that the syntax doesn't resemble JavaScript... but that doesn't affect any technical features of the language at the end of the day, so it should not be relevant! And for that reason I believe calling TypeScript a superset of JavaScript is helpful to start working with it but it just describes syntax similarity, which influences no other part of the language itself. (By the way, there is a project called AssemblyScript - which, using your point of view, could be viewed as a subset of TypeScript - that compiles to Web Assembly instead of JavaScript, albeit not all types are supported. Check out github.com/AssemblyScript/assembly...)

                  • Amin
                    AminMar 29, 2020

                    Do you have any examples of unintended things you could do on the javascript side using Elm?

                    • David Trapp
                      David TrappMar 29, 2020

                      You mean the opposite, right? Like, breaking Elm using JavaScript? Since this is what we were talking about.

                      Here is an example: gist.github.com/CherryDT/7ced888d7...

                      You will never figure out why it starts falling apart after entering PWN3D into the field just looking at the Elm file.

                      But if you see the rogue script inside the HTML file which reaches into Elm's "protected" world and breaks assumptions (in this case the assumption that reading the property of an HTML element never throws an error), you can see why it behaves like that.

                      (And in the same way I can of course pass unexpected things from JavaScript into the TypeScript world that break assumptions there, as you said. The only protection against this sort of thing would be to rigorously check every detail at runtime and that would kill performance obviously.)

                      • Amin
                        AminMar 29, 2020

                        Interesting, thanks for your repro.

                        As I was suspecting, it takes a script written in JavaScript. So you example will work in the case of a malicious attacker having the ability to inject scripts. But I hope we don't do that to our own applications!

                        I think that we can agree that in the context of an application, one won't use the JavaScript DOM API and the Elm DOM API at the same time or it would be pointless to use Elm.

                        But I'm really grateful for your example because this kind of attack might be harmful if an attacker has access to a flaw that allows him to inject scripts as I said. I wonder if using the MutationObserver API in some way can prevent the outside from mutating some HTMLElements (but it would probably mean blocking the Elm runtime from mutating it too).

                        • David Trapp
                          David TrappMar 29, 2020

                          MutationObserver won't help here, especially since my attack is against the JavaScript object representing that DOM element and not even the DOM element itself.

                          But one point I'm getting confused about right now is this: The reason I even chimed in in the first place was this statement of yours:

                          My point was to say that you can have interfaces, tuples, and everything you want, this won't prevent me from using your library, compile it to javascript and use the wrong types because in the runtime, it is JavaScript.

                          ...which I read as "things outside of your bubble can interfere with your code at runtime in unexpected ways, rendering your compile-time protections useless", which in my opinion is true for pretty much every language, hence the example with Elm now.

                          If we are talking about staying inside the bubble anyway, then for me that point is moot, because in your own TypeScript code you also won't add scripts that hurt yourself...

                          And if you are just talking about the defined interfaces between the languages, then it's true that JSON data in ports won't be able to trip up Elm that easily but that's just because Elm does (expensive) runtime validation there for you. TypeScript leaves this task up to you. (But there are tools out there to help with that, such as the validator io-ts github.com/gcanti/io-ts/ which does runtime validation with given constraints and automatically also creates the correct TS types from it based on your constraints.) Just like in my C++ example: C++ also won't do runtime type checking (in fact it cannot because unlike JavaScript as "base language", machine code doesn't have types, just raw bytes in memory whose meaning is up to you to know).

                          And if that is actually the point here, then again I don't see how "doesn't do runtime type checking" makes TypeScript any less of a language than Elm. :-)

                          (Oh and, "in Elm you have to use Elm" isn't true either, because there are (hacky) ways to synchronously invoke your Elm function from JS without implicit type marshalling, and then you can pass arbitrary data to it including data with traps like properties that throw and so on. And since you are talking about other people using your code, you can't force them not to do that either, if they think they need it. See lazamar.github.io/calling-elm-func... for example. Just like only providing a .ts file won't prevent people from compiling it to .js and circumventing your types, providing only an .elm file won't prevent people from compiling it to .js and messing with it either. Or, as mentioned, providing only a .cpp file in C++ won't prevent people from compiling to an .a file and statically linking to it from a language that doesn't care about the original function signatures your C++ functions had.)

  • lionel-rowe
    lionel-roweOct 9, 2021

    You can now use labeled tuples, available since TypeScript 4.0!

    Really useful for APIs with a standard ordering of defined arguments, such as HTML5 canvas's handling of rects:

    type Rect = [x: number, y: number, width: number, height: number]
    
    const canvas = document.querySelector('#my-canvas')
    const ctx = canvas.getContext('2d')
    
    const img = document.querySelector('#my-semitransparent-image')
    const { width, height } = img
    
    const rect: Rect = [0, 0, width, height]
    
    ctx.fillStyle = '#f00'
    ctx.fillRect(...rect)
    ctx.drawImage(img, ...rect)
    
    Enter fullscreen mode Exit fullscreen mode
Add comment