Allowing multiple differently shaped interfaces as TypeScript return types

守給你的承諾、 提交于 2021-02-05 06:43:25

问题


I have a function that takes a few parameters and generates objects that will be passed into an external process. Sine I have no control over the shapes that need to be ultimately created, I have to be able to take some varying parameters to my function and assemble them into the appropriate objects. Here's a really basic example that exhibits the issue I'm having:

interface T1A {
    type: 'type1';
    index: number;
}

interface T1B {
    type: 'type1';
    name: string;
}

interface T2A {
    type: 'type2';
    index: number;
}

interface T2B {
    type: 'type2';
    name: number;
}

function hello(type: 'type1' | 'type2'): T1A | T1B | T2A | T2B {
    return {type, index: 3}
}

This particular function complains that 'type1' is not assignable to 'type2'. I read a bit about type guarding and figured out how to make this simple example happpy:

function hello(type: 'type1' | 'type2'): T1A | T1B | T2A | T2B {
    if (type === 'type1') {
        return {type, index: 3}
    } else {
        return {type, index: 3}
    }
}

However, I don't see why this is necessary. The type check is doing absolutely nothing to the return value's possibilities. Based on the fact my parameter takes only one of two explicit strings, the single return statement is guaranteed to return one of T1A or T2A. In my actual use-case I have more types and parameters but the way I've assigned them it's always guaranteed to return at least one of the specified interfaces. It seems that the union is not really a 'this OR this' when dealing with interfaces. I am trying to break up my code to handle each individual type, but when there's 8 different possibilities, I end up with a lot of extra if/else blocks which seem useless.

I have also tried using types type T1A = {...};

Am I misunderstanding something about the unions or is this a bug, or a simpler way to deal with it?


回答1:


TypeScript doesn't, in general, perform the kind of propagation of unions up from properties. That is, while every value of the type {foo: string | number} should be assignable to the type {foo: string} | {foo: number}, the compiler does not see those as mutually assignable:

declare let unionProp: { foo: string | number };
const unionTop: { foo: string } | { foo: number } = unionProp; // error!

Aside from weird things that happen when you start mutating properties of such types, it's just too much work in general for the compiler to do that all the time, especially when you have multiple union properties. A relevant comment in microsoft/TypeScript#12052 says:

this sort of equivalence only holds for types with a single property and isn't true in the general case. For example, it wouldn't be correct to consider { x: "foo" | "bar", y: string | number } to be equivalent to { x: "foo", y: string } | { x: "bar", y: number } because the first form allows all four combinations whereas the second form only allows two specific ones.

So it's not a bug per se, but a limitation: the compiler is only going to spend so much time playing with unions to see if some code is valid.


In TypeScript 3.5, support was added to do the above computations specifically for the case of discriminated unions. If your union has properties that can be used to distinguish between members of the union (which means singleton types like string literals, numeric literals, undefined, or null) in at least some member of the union, then the compiler will verify your code the way you want it.

This is why it suddenly works if you change T1A | T2A | T1B | T2B to just T1A | T2A, and can be a possible answer to your question:

function hello(type: 'type1' | 'type2'): T1A | T2A | T1B | T2B {
    const x: T1A | T2A = { type, index: 3 }; // okay
    return x;
}

The latter is a discriminated union: the type property tells you which member you have. But the former is not: the type property can distinguish between T1A | T1B and T2A | T2B, but there's no property to further subdivide. No, the mere absence of index or name in the type does not count as a discriminant, since a value of type T1A may have a name property; types in TypeScript are not exact.

The above code works because the compiler can verify that x is of type T1A | T2A, and can then verify that T1A | T2A is a subtype of the full T1A | T2A | T1B | T2B. So if you're happy with a two-step process, this is a possible solution for you.


If you want T1A | T2A | T1B | T2B to be a discriminated union, you need to modify the constituent types' definitions so as to truly discriminate by at least one common property. Like, say, this:

interface T1A {
    type: 'type1';
    index: number;
    name?: never;
}

interface T1B {
    type: 'type1';
    name: string;
    index?: never;
}

interface T2A {
    type: 'type2';
    index: number;
    name?: never;
}

interface T2B {
    type: 'type2';
    name: number;
    index?: never;
}

By adding those optional properties of type never, you can now use type, name, and index as discriminants. The name and index properties will sometimes be undefined, a singleton type that is supported to distinguish types. And then this works:

function hello(type: 'type1' | 'type2'): T1A | T2A | T1B | T2B {
    return { type, index: 3 }; // okay
}

So those are two options. Another option which is always available in such situations where you know something is safe but the compiler doesn't, is to use a type assertion:

function hello2(type: 'type1' | 'type2') {
    return { type, index: 3 } as T1A | T2A | T1B | T2B
}

Okay, hope that helps; good luck!

Playground link to code



来源:https://stackoverflow.com/questions/60172520/allowing-multiple-differently-shaped-interfaces-as-typescript-return-types

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!