24

Whenever I use array.map on a tuple, Typescript infers it as a generic array. For instance, here are some pieces of a simple 3x3 sudoku game:

const _ = ' ' // a "Blank"

type Blank = typeof _

type Cell = number | Blank

type Three = [Cell, Cell, Cell]

type Board = [Three, Three, Three]

const initialBoard: Board = [
    [_, 1, 3],
    [3, _, 1],
    [1, _, _],
]

// Adds a `2` to the first cell on the first row
function applyMove(board: Board): Board {
    // 👇errors here
    const newBoard: Board =  board.map((row: Three, index: number) => {
        if (index === 0) return <Three> [2, 1, 3]
        return <Three> row
    })
    return newBoard
}

function applyMoveToRow(row: Three): Three {
    // return [2, 1, 3] // This works
    const newRow: Three = [
        2,
        ...row.slice(1, 3)
    ]
    return newRow
}

The TS error is:

Type '[Cell, Cell, Cell][]' is missing the following properties from type 
 '[[Cell, Cell, Cell], [Cell, Cell, Cell], [Cell, Cell, Cell]]': 0, 1, 2 .  

here it is in a TS Playground.

Is there any way to tell typescript that, when I'm mapping over a tuple, it's going to return a tuple of the same kind, instead of just an array? I've tried being very explicit, annotating all of my return values, etc, but it's not doing the trick.

There's a discussion on the Typescript github about this: https://github.com/Microsoft/TypeScript/issues/11312

But I haven't been able to get a solution out of it.

5
  • Are you asking about your slice() problem too? Commented Sep 12, 2019 at 19:24
  • What version of TypeScript are you using? It appears as though it was fixed in 3.4... Commented Sep 12, 2019 at 19:26
  • Of course, the easiest way of solving this is to just add ` as Board` after the map. And ` as Three` after the ] for the slice() problem... Commented Sep 12, 2019 at 19:34
  • @HereticMonkey yeah, as Board does do the trick, but feels like a "patch". Also, the playground is in TS 3.5, and still showing the errors there. Commented Sep 12, 2019 at 19:42
  • Yeah, I just noticed that... Might be worth raising an issue on their GitHub. Might be a regression. Commented Sep 12, 2019 at 19:45

3 Answers 3

24

TypeScript does not attempt to preserve tuple length upon calling map(). This feature was requested in microsoft/TypeScript#11312, implemented in microsoft/TypeScript#11252, and reverted in microsoft/TypeScript#16223 due to problems it caused with real world code. See microsoft/TypeScript#29841 for details.

But if you want, you could merge in your own declaration for the signature of Array.prototype.map(), to account for the fact that it preserves the length of tuples. Here's one way to do it:

interface Array<T> {
  map<U>(
    callbackfn: (value: T, index: number, array: T[]) => U,
    thisArg?: any
  ): { [K in keyof this]: U };
}

This uses polymorphic this types as well as array/tuple mapped types to represent the transformation.

Then your code could be written like the following:

function applyMove(board: Board): Board {
  return board.map(
    (row: Three, index: number) => (index === 0 ? applyMoveToRow(row) : row)
  );
}

function applyMoveToRow(row: Three): Three {
  return [2, row[1], row[2]];
}

and there'd be no error. Note that I didn't bother trying to deal with Array.prototype.slice(). It would be a large amount of effort to try to represent what slice() does to a tuple type, especially since there's no real support for tuple length manipulation... meaning you might need a bunch of overload signatures or other type trickery to get it done. If you're only going to use slice() for short arrays, you might as well just use index access like I did above with [2, row[1], row[2]] which the compiler does understand.

Or if you're going to use it for longer arrays but a small number of times in your code, you might just want to use a type assertion to tell the compiler that you know what you're doing. For that matter, if you're only doing map() a small number of times, you can use a type assertion here too instead of the above redeclaration of map()'s signature:

function applyMove(board: Board): Board {
  return board.map(
    (row: Three, index: number) => (index === 0 ? applyMoveToRow(row) : row)
  ) as Board; // assert here instead of redeclaring `map()` method signature
}

Either way works... type assertions are less type safe but more straightforward, while the declaration merge is safer but more complicated.

Hope that helps; good luck!

Link to code

Sign up to request clarification or add additional context in comments.

4 Comments

I 'm wondering how do you track all these typescript issues?
@captain-yossarian a combination of GitHub search and just general interest in reading the issues.
Upd.: and there is a way to not only preserve the length, but also preserve the resulting type of each inidivual tuple element after application of the mapper function ^_^
If you're willing to emulate HKTs then yes, there is a way, but the emulations are not easy unless you're already intimately familiar with HKTs and willing to jump through weird TS type juggling hoops to do it.
1

If you don't mind tweaking the way you assign initialBoard you can change your Board definition to this:

interface Board  {
    [0]: Three,
    [1]: Three,
    [2]: Three,
    map(mapFunction: (row: Three, index: number, array: Board) => Three): Board;
}

This is how you have to change the way you assign a literal to a Board:

const initialBoard: Board = <Board><any>[
    [_, 1, 3],
    [3, _, 1],
    [1, _, _],
]

2 Comments

This is great, I didn't realize you could add numbered keys to interfaces like this. Could you explain why the initialBoard declaration needs the additional <Board><any>?
because Board is no longer declared as an array, so you can't just assign an array to your Board variable without casting. And if you just use <Board> you'll get an error about a possibly invalid cast that basically says "if you really know what you are doing, then first cast it to any, then to Board".
0

If you need to also infer the resulting type, you can use the approach from this github issue that imitates higher kinded types:

Definition of utility types

export interface HKT {
    param: unknown,
    result: unknown,
}

/** @see https://github.com/microsoft/TypeScript/issues/40928 */
export type Apply<f extends HKT, x>
    = (f & { param: x })["result"];

Your code

interface ToString extends HKT {
    result: `${this['param']}`,
}

declare function mapTuple<const TTuple extends readonly [...unknown[]], TFunc extends HKT>(
    srcTuple: readonly [...TTuple],
    mapper: (arg: TTuple[`${number}` & keyof TTuple]) => Apply<TFunc, TTuple[`${number}`]>
): {
    [i in keyof TTuple]: i extends `${number}` ? Apply<TFunc, TTuple[i]> : TTuple[i]
};

const nums = [1,2,3] as const;
let mapped: readonly ["1", "2", "3"] =
    mapTuple<typeof nums, ToString>(nums, n => `${n}`);

See how it compiles in typescript playground

enter image description here

So you basically just need to define an interface for each map callback to express the type of the function that transforms element from one type to another and to explicitly pass that interface as generic type parameter to the mapTuple(). Linked github issue has more examples, including conditional types.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.