1

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()?

simbabque
  • 53,749
  • 8
  • 73
  • 136

2 Answers2

5

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).

T.J. Crowder
  • 1,031,962
  • 187
  • 1,923
  • 1,875
  • This is great, thank you. Where in my codebase would `isPoint` and `stringToPoint` live? There's one exported class in its own file that defines the my business logic, and I've defined and exported my types there. The script where the problem occurred munges the input . – simbabque May 21 '22 at 08:55
  • @simbabque - That's up to you. I generally keep them right next to the type definition, so that if I change one the other is right there for me to change. – T.J. Crowder May 21 '22 at 08:56
2

Apart from @T.J. Crowder 's solution, which is perfectly fine, there is another one, which is more focused on type inference.

We can write a function which will infer return type of provided argument. For example:


split('1,2') // [1,2]

In order to achieve it, we need to write an utility type which will parse a string an convert all stringified digits into numerical array. We need :

  1. To generate a range of numbers
  2. Add utility type for convertion a stringified number to numeric digit
  3. Write a type which will iterate through each character in passed argument , converts it to number and add it to the list.

To generate number range, you can check my article and answer:

type MAXIMUM_ALLOWED_BOUNDARY = 100

type ComputeRange<
    N extends number,
    Result extends Array<unknown> = [],
    > =
    (Result['length'] extends N
        ? [...Result, Result['length']][number]
        : ComputeRange<N, [...Result, Result['length']]>
    )

type ResultRange = ComputeRange<MAXIMUM_ALLOWED_BOUNDARY> // 1 | 2 | 3 .. 100

Then, we need to convert stringified number to numeric digit:

type ToInt<
    DigitRange extends number,
    Digit extends string
    > =
    /**
     * Every time when you see: [Generic] extends any ...
     * It means that author wants to turn on distributivity 
     * https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types
     */
    DigitRange extends any
    /**
     * After Distributivity was turned on, it means that [true branch] is applied to 
     * each element of the union separately
     * 
     * SO, here we are checking whether wrapped into string numerical digit is assignable to stringified digit
     */
    ? `${DigitRange}` extends Digit
    /**
     * If yes, we return numerical digit, without string wrapper
     */
    ? DigitRange
    : never
    : never

type Result = ToInt<1 | 2 | 3 | 4 | 5, '2'> // 2

Now, we need a type which will iterate through each character in string and converts it into numerical digit:

type Inference<
    S extends string,
    Tuple extends number[] = []
    > =
    /**
     * Check if it is the end of string
     */
    S extends ''
    /**
     * If it is the end - return accumulator generic
     */
    ? Tuple
    /**
     * Otherwise infer first and second digits which are separated by comma and rest elements
     */
    : S extends `${infer Fst},${infer Scd}${infer Rest}`
    /**
     * Paste infered digits into accumulator type Tuple and simultaneously convert them into numerical digits
     */
    ? Inference<
        Rest, [...Tuple, IsValid<Fst>, IsValid<Scd>]
    >
    : never

WHole example:


type MAXIMUM_ALLOWED_BOUNDARY = 100

type ComputeRange<
    N extends number,
    Result extends Array<unknown> = [],
    > =
    (Result['length'] extends N
        ? [...Result, Result['length']][number]
        : ComputeRange<N, [...Result, Result['length']]>
    )

type ResultRange = ComputeRange<MAXIMUM_ALLOWED_BOUNDARY> // 1 | 2 | 3 .. 100

type ToInt<
    DigitRange extends number,
    Digit extends string
    > =
    DigitRange extends any
    ? `${DigitRange}` extends Digit
    ? DigitRange
    : never
    : never

type Result = ToInt<1 | 2 | 3 | 4 | 5, '2'> // 2

type IsValid<Elem extends string> = ToInt<ResultRange, Elem>


type Inference<
    S extends string,
    Tuple extends number[] = []
    > =
    S extends ''
    ? Tuple
    : S extends `${infer Fst},${infer Scd}${infer Rest}`
    ? Inference<
        Rest, [...Tuple, IsValid<Fst>, IsValid<Scd>]
    >
    : never

declare const split: <Str extends `${number},${number}`>(str: Str) => Inference<Str>

split('1,2') // [1,2]

Playground

As you might have noticed, my focus was more on types rather than business logic. Hope it helps to understand how TS type system works.

Btw, since TS 4.8 (nightly version) you can infer digit from template literal without any recursion hacks. See this answer