1

In a TypeScript project, I have an array of containers that carry a type property and some additional data, depending on their type.

type Container<Type extends string> = {
    type: Type;
}

type AContainer = Container<"a"> & {
    dataA: number;
}

type BContainer = Container<"b"> & {
    dataB: boolean;
}

const data: (AContainer | BContainer)[] = [
    { type: "a", dataA: 17 },
    { type: "b", dataB: true }
];

My goal is to write a function that allows me to select an element from that array by its type, with full type safety. Something like this:

const getByType = <T extends string>(data: Container<string>[], type: T): Container<T> => {
    for (const c of data) {
        if (c.type === type) return c;
    }
    throw new Error(`No element of type ${type} found.`);
};

const dataA: AContainer = getByType(data, "a");

The problem is trying to convince TypeScript that the function is type-safe, and the return value is an element of the original array and has the requested type.

Here's my best attempt:

const getByType = <ContainerType extends Container<string>, Type extends string>(data: (ContainerType & Container<string>)[], type: Type): ContainerType & Container<Type> => {
    for (const c of data) {
        if (c.type === type) return c;
    }
    throw new Error(`No element of type ${type} found.`);
};

However, TypeScript neither understands that the comparison c.type === type ensures a Container<string> turns into a Container<Type>, nor that the return type of an example call, AContainer | (Container<"b"> & { dataB: boolean; } & Container<"a">), is equal to AContainer because of the conflict in Container<"b"> & Container<"a">. The first problem can be solved by using a type predicate as the one in the following code block (although that kind of feels like cheating), but I have not found a solution for the second problem.

const isContainer = <Type extends string>(c: Container<string>, type: Type): c is Container<Type> => {
    return typeof c === "object" && c.type === type;
};

Is there any way to get this to work? I'd prefer it if both getByType itself and its use were type-safe, but if that's not possible, I want at least the usage of getByType to not require any unsafe type assertions.

I can change the definitions of the container types, but the actual data is fixed. (For background: xml2js XML parser.)

fefrei
  • 885
  • 1
  • 10
  • 27

1 Answers1

1

We can use infer and Extract in order to achieve the goal. Consider:

const getByType = <ContainerType extends Container<string>, Type extends ContainerType extends Container<infer T> ? T : never, Chosen extends Type>(data: ContainerType[], type: Chosen) => {
    for (const c of data) {
        if (c.type === type) return c as Extract<ContainerType, {type: Chosen}>;
    }
    throw new Error(`No element of type ${type} found.`);
};

const containerA: AContainer = {
  dataA: 1,
  type: "a"
} 
const containerB: BContainer = {
  dataB: true,
  type: "b"
}
const b = getByType([containerB, containerA], 'b')
// b is infered as BContainer 

Few things to pay attention:

  • type: ContainerType extends Container<infer T> ? T : never We say argument needs to contain exact available type in the given array
  • Extract<ContainerType, {type: Chosen}> we say we return element of the union with {type: Chosen} and it means the member with this exact type

We have here also strict type over second argument, in the example is narrowed to a | b

Playground

Maciej Sikora
  • 19,374
  • 4
  • 49
  • 50
  • It looks like the return value of `getByType` has type `Container<"a" | "b">`, not `AContainer`, so I cannot access the `dataA` property afterwards. – fefrei Feb 11 '20 at 12:05
  • The return type is now `Container<"a">`, which is better, but still not enough – it needs to be `AContainer` to allow me to access the `dataA` property. (The crucial bit of information that allows this, in theory, is that `data` has type `(AContainer | BContainer)[]`, so it cannot contain an arbitrary container claiming to have type `a`, it truly needs to be an `AContainer`.) – fefrei Feb 11 '20 at 12:12
  • Playing around with that a bit more, TypeScript normally does detect and eliminate empty union types. This may be a bug, which I reported [here](https://github.com/microsoft/TypeScript/issues/36736). I'd still be happy for a solution that works with current TypeScript, though. – fefrei Feb 11 '20 at 12:42
  • The type is still inferred as `Container<"b">` for me, not `BContainer`. (Using TypeScript 3.7.5.) – fefrei Feb 11 '20 at 13:28
  • Probably some copy/paste mistake I did, check the playground link I added – Maciej Sikora Feb 11 '20 at 13:36
  • Indeed, `Extract` works! It requires the typecast within `getByType`, so I still hope TypeScript improves here, but this is _way_ better than anything I've found. **Thank you!** – fefrei Feb 11 '20 at 13:51