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

1 Answer 1

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

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

6 Comments

It looks like the return value of getByType has type Container<"a" | "b">, not AContainer, so I cannot access the dataA property afterwards.
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.)
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. I'd still be happy for a solution that works with current TypeScript, though.
The type is still inferred as Container<"b"> for me, not BContainer. (Using TypeScript 3.7.5.)
Probably some copy/paste mistake I did, check the playground link I added
|

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.