Define array with multiple types in TypeScript

eye-catch JavaScript/TypeScript

Haven’t you thought that you want to define data type for each element in an array? The first element is number and second is string and so on… If multiple data types exist in an array intellisense doesn’t work well because the data type of each element becomes union type like string | number . If we know the number of elements in the array and the data type we can define data type for each element. It is know as Tuple.

Sponsored links

Arbitrary number of elements with union type

If an array could have a different data type we can define the type like this below.

const array1: (string | number)[] = ["Foo", 20, "hoo", "foo"];

We can add as many elements as we want.

Sponsored links

Fixed number of elements

If the first element is always a string and the second element is a number we can define it like this.

const array2: [string, number] = ["Foo", 20];

It is impossible to add a third element to the array because the data type [string, number] says that its array has two elements. It is also impossible to assign [20, "Foo"] .

Fixed number of elements with variable name

We can give a name if we want. TypeScript version must be 4.0 or later for this labeled tuple.

const array3: [name: string, age: number] = ["Foo", 20];

Array of array that has fixed number of elements

If we want to define an array that has a key-value pair, we can write like this.

const array4: [string, number][] = [
    ["Foo", 20],
    ["Hoo", 30],
    ["Koo", 40],
];

This might be used in unit tests when we want to define test value and expected value in an object/array for a parameter test.

Example use case in unit test

Following is the example that I want to define the data type in advance. I’ve read this kind of code in my project and it was written in unit test. Assume that TypeValueMap is the actual return value from a function and console output is validation like expect(result).to.equal(expected) by chai module.

enum MyValue {
    Four = 4,
    Five = 5,
    Six = 6,
}

enum MyType {
    AAA = "type a",
    BBB = "type b",
    CCC = "type c",
}

const TypeValueMap = new Map([
    [MyType.AAA, MyValue.Four],
    [MyType.BBB, MyValue.Five],
    [MyType.CCC, MyValue.Six],
]);

const expected = [
    [MyType.AAA, MyValue.Four],
    [MyType.BBB, MyValue.Five],
    [MyType.CCC, MyValue.Six],
];
expected.forEach((data) => {
    const result = data[1] === TypeValueMap.get(data[0] as MyType);
    console.log(`type: ${data[0]}, result: ${result}`);
});

There are multiple test data and expected data. Each of them should pass the test. However, data[0] and data[1] is not clear what it is. So what I wanted to do is to give them a name like below.

expected.forEach((data) => {
    const type = data[0];
    const value = data[1];
    const result = value === TypeValueMap.get(type as MyType);
    console.log(`type: ${type}, result: ${result}`);
});

It doesn’t look nice to me. It can be improved like this below.

expected.forEach((data) => {
    const [type, value] = data;
    const result = value === TypeValueMap.get(type as MyType);
    console.log(`type: ${type}, result: ${result}`);
});

expected.forEach(([type, value]) => {
    const result = value === TypeValueMap.get(type as MyType);
    console.log(`type: ${type}, result: ${result}`);
});

We need as keyword here in either way because its data type is MyType | MyValue but it is better not to use as keyword in production code because it leads to an unexpected result. It is not a problem to use it in unit tests because the test can detect the error if something is wrong.

The following is the final code.

const expected: [MyType, MyValue][] = [
    [MyType.AAA, MyValue.Four],
    [MyType.BBB, MyValue.Five],
    [MyType.CCC, MyValue.Six],
];

expected.forEach(([type, value]: [MyType, MyValue]) => {
    const result = value === TypeValueMap.get(type);
    console.log(`type: ${type}, result: ${result}`);
});

as keyword is no longer necessary because the first element is definitely MyType . It is type-safe now.

Bad code example

This is a bad example.

type SpecialArray = [number, string, MyType, number, MyValue];
function doSomething(array: SpecialArray) {
    const [
        id,
        name,
        type,
        sortOrder,
        value,
    ] = array;
    console.log(`id: ${id.toString().padStart(4, "0")}`);
    console.log(`name: ${name.trim()}`);
    console.log(`type: ${TypeValueMap.get(type)}`);
    console.log(`sortOrder: ${sortOrder}`);
    console.log(`is value 4?: ${MyValue.Four === value}`);
}
const array: SpecialArray = [32, " bee  ", MyType.BBB, 1, MyValue.Six];
console.log(doSomething(array));
// id: 0032
// name: bee
// type: 5
// sortOrder: 1
// is value 4?: false

As you can see here, the function can be called without a cast. It’s better than casting like array[1].toString().trim().
However, this way is basically not a good way to go. We should instead define an interface in this example because there is no reason to use an array here. I hope such an array that contains different data types is used only in unit tests. This is actually not readable.

There are two ways to go if we really need an array that contains different data types.
The first solution is to create a new array by filtering it.

const stringArray = array.filter((data) => typeof data === "string");
const numberArray = array.filter((data) => typeof data === "number");
stringArray.forEach((str) => doSomethingForString(str));
numberArray.forEach((num) => doSomethingForNumber(num));

The second solution is to define an interface and implement it for each data type.

const array = [22, "hoge", 52];

interface Command {
    func: (arg: unknown) => void;
}
class StringCommand implements Command {
    public func(arg: unknown): void {
        if (typeof arg !== "string") {
            throw new Error("arg is not string.");
        }
        console.log(arg.trim());
    }
}
class NumberCommand implements Command {
    public func(arg: unknown): void {
        if (typeof arg !== "number") {
            throw new Error("arg is not number.");
        }
        console.log(arg * 2);
    }
}
function getCommand(arg: unknown): Command {
    const type = typeof arg;
    switch (type) {
        case "string": return new StringCommand();
        case "number": return new NumberCommand();
        default: throw new Error("Unsupported data type.");
    }
};
array.forEach((data) => {
    const instance = getCommand(data);
    instance.func(data);
});
// 44
// hoge
// 104

We can define a different process for each data type in this way. It looks more complicated though…

Summary

We can use a tuple if we want to define an array with the fixed number of elements. It looks following.

const array1: (string | number)[] = ["Foo", 20, "hoo", "foo"];
const array2: [string, number] = ["Foo", 20];
// typescript version must be 4.0 or later
const array3: [name: string, age: number] = ["Foo", 20];
const array4: [string, number][] = [
    ["Foo", 20],
    ["Hoo", 30],
    ["Koo", 40],
];

Comments

Copied title and URL