Home > front end >  How do I tell Typescript that I expect exactly 2 elements from split()?
How do I tell Typescript that I expect exactly 2 elements from split()?

Time:05-22

I want to type an array of arrays, where each element has either two or four numbers.

[
  [ 1, 2 ],
  [ 1, 2 ],
  [ 1, 2, 3, 4]
]

I've declared these types.

type Point = [number, number];
type Line = [number, number, number, number];
type Record = Array< Line | Point >; 

But when I try to make a Point from a string of comma separated numbers, I get an error.

const foo:Point = "1,2".split(",").map(parseInt);

Type 'number[]' is not assignable to type 'Point'. Target requires 2 element(s) but source may have fewer.ts(2322)

I understand that it can't know whether the split() returns exactly 2 elements. I could make the Point a number[], but that feels like it defeats the point of a strongly typed system.

I have tried to do split(pattern, 2), but that didn't make a difference, and I also don't know how I would say "split to 2 or 4 elements".

const foo:Point = "1,2"
  .split(",", 2)
  .map(parseInt)
  .map((e) => [e[0], e[1]]); // .slice(0, 2) doesn't work either

The above would seem like it has in fact got exactly two elements, but it also doesn't work.

How do I convince it that there will be two numbers returned from the split()?

CodePudding user response:

Preface: You can't reliably use parseInt directly as a map callback, because parseInt accepts multiple parameters and map passes it multiple arguments. You have to wrap it in an arrow function or similar, or use Number instead. I've done the latter in this answer.

It depends on how thorough and typesafe you want to be.

You could just assert that it's a Point, but I wouldn't in most cases:

// I wouldn't do this
const foo = "1,2".split(",").map(Number) as Point;

The problem being that if the string doesn't define two elements, the assertion is wrong but nothing checks the assertion. Now, in that particular case, you're using a string literal, so you know it's a valid Point, but in the general case you wouldn't.

Since you'll probably want to convert strings to Point instances in more than one place, I'd write a utility function that checks the result. Here's one version:

const stringToPoint = (str: string): Point => {
    const pt = str.split(",").map(Number); // (You can't reliably use `parseInt` as a `map` callback)
    if (pt.length !== 2) {
        throw new Error(`Invalid Point string "${str}"`);
    }
    return pt as Point;
};

That checks that the array has two elements and throws an error if it doesn't, so the type assertion following it is valid.

This is where the question of how thorough you want to be comes in. You might want to have a single central authoritative way of checking that something is a Point, not least so you can change it if your definition of Point changes (unlikely in this case). That could be a type predicate ("type guard function") or a type assertion function.

Here's a type predicate:

function isPoint(value: number[]): value is Point {
    return value.length === 2;
}

Then stringToPoint becomes:

const stringToPoint = (str: string): Point => {
    const pt = str.split(",").map(Number); // (You can't reliably use `parseInt` as a `map` callback)
    if (!isPoint(pt)) {
        throw new Error(`Invalid Point string "${str}"`);
    }
    return pt;
};

Playground link

And should you want one, here's a type assertion function that you might use in places you think you know that the value is a valid Point:

function assertIsPoint(value: number[]): asserts value is Point {
    if (!isPoint(value)) {
        throw new Error(`Invalid Point found: ${JSON.stringify(value)}`);
    }
}

That lets you do things like this:

const pt = "1,2".split(",").map(Number); // (You can't reliably use `parseInt` as a `map` callback)
assertIsPoint(pt);
// Here, `pt` has the type `Point`

Playground link

I wouldn't do that literally (I'd use stringToPoint), but it can be handy when (say) you're deserializing something that should definitely be correct (for instance, a Point you get from localStorage and parse via JSON.parse).

  • Related