Typescript: Union of subtypes should be usable as the supertype

Created on 19 Apr 2020  路  5Comments  路  Source: microsoft/TypeScript


TypeScript Version: 3.8.3 and v3.9.0-dev.20200418


Search Terms:
common type subtype union

Code

interface Cata<OkIn, OkOut, ErrIn, ErrOut> {
    Ok(value: OkIn): OkOut
    Err(err: ErrIn): ErrOut
}

type CataOk<OkIn, OkOut> = Cata<OkIn, OkOut, never, unknown>
type CataErr<ErrIn, ErrOut> = Cata<never, unknown, ErrIn, ErrOut>

interface BaseResult<T, E> {
    readonly ok: boolean
    cata<OkOut, ErrOut>(cata: Cata<T, OkOut, E, ErrOut>): OkOut | ErrOut
}
export class Ok<T> implements BaseResult<T, never> {
    readonly ok = true as const
    constructor(readonly value: T) {}
    cata<OkOut>(cata: CataOk<T, OkOut>) {
        return cata.Ok(this.value)
    }
}
export class Err<E> implements BaseResult<never, E> {
    readonly ok = false as const
    constructor(readonly error: E) {}
    cata<ErrOut>(cata: CataErr<E, ErrOut>) {
        return cata.Err(this.error)
    }
}

export type Result<T, E> = Ok<T> | Err<E>

// tests

declare const u: Result<number, Error>

u.cata({
    Ok(v) { return '2' },
    Err() { return 3 },
})

Expected behavior:

No error (Liskov substitution principle).

Actual behavior:

This expression is not callable.
Each member of the union type (<OkOut>(cata: CataOk<number, OkOut>) => OkOut) | (<ErrOut>(cata: CataErr<Error, ErrOut>) => ErrOut) has signatures, but none of those signatures are compatible with each other. ts(2349)

Workaround:

export type Result<T, E> = BaseResult<T, E> & (Ok<T> | Err<E>)

Playground Link

Related Issues: #37704.

Needs Investigation

Most helpful comment

Our rules for calls on unions are quite strict, as we do not have the capability to "resolve the call on each union member". When attempting to call a union, the call is only allowed of the signatures are sufficiently similar such that we can unify them into a single signature.

In this case we cannot, because each of the input signatures has a different type parameter (declared in a different location), which is going to prevent us from merging them. (While we could combine the parameter list, making inference then behave the same way on the input arguments as the component signatures isn't something we can do - the combined parameter type is inferred to by a potentially different set of inference heuristics).

The intersection with the base type workaround succeeds, as then in the resulting union, each type shares a common overload (the one intersected from the base), which we can trivially resolve against. It's worth noting that class inheritance works differently than intersection here - intersection merges signature lists, while when subtyping a class, you override them.

All 5 comments

Our rules for calls on unions are quite strict, as we do not have the capability to "resolve the call on each union member". When attempting to call a union, the call is only allowed of the signatures are sufficiently similar such that we can unify them into a single signature.

In this case we cannot, because each of the input signatures has a different type parameter (declared in a different location), which is going to prevent us from merging them. (While we could combine the parameter list, making inference then behave the same way on the input arguments as the component signatures isn't something we can do - the combined parameter type is inferred to by a potentially different set of inference heuristics).

The intersection with the base type workaround succeeds, as then in the resulting union, each type shares a common overload (the one intersected from the base), which we can trivially resolve against. It's worth noting that class inheritance works differently than intersection here - intersection merges signature lists, while when subtyping a class, you override them.

Thank you for your thorough answer.

each of the input signatures has a different type parameter (declared in a _different location_)

Are you referring to generic type or its instantiation? If the former, I disagree that it depends on location of the declaration:

type AnyFn = (x: never) => unknown

interface Cata<OkFn extends AnyFn, ErrFn extends AnyFn> {
    Ok: OkFn
    Err: ErrFn
}

interface BaseResult<T, E> {
    readonly ok: boolean
    cata<ValueOut, ErrorOut>(
        cata: Cata<(x: T) => ValueOut, (x: E) => ErrorOut>
    ): ValueOut | ErrorOut
}

export class Ok<T> implements BaseResult<T, never> {
    readonly ok = true as const
    constructor(readonly value: T) {}
    cata<Out>(cata: Cata<(value: T) => Out, AnyFn>) {
        return cata.Ok(this.value)
    }
}
export class Err<E> implements BaseResult<never, E> {
    readonly ok = false as const
    constructor(readonly error: E) {}
    cata<Out>(cata: Cata<AnyFn, (error: E) => Out>) {
        return cata.Err(this.error)
    }
}

export type Result<T, E> = Ok<T> | Err<E>

// tests

declare const v: Result<number, Error>

const o = v.cata({
    Ok(v) {
        return "2"
    },
    Err() {
        return 3
    },
})

Playground

This code (evolved in the meantime) doesn't alias Cata, yet it hits analogous error.

Are you referring to generic type

The generic type parameter on the method itself is to what I am referring. Each method declaration has a distinct list of them.

I find that example really difficult to read, but i assume it's basically the same as what is happening here:

interface Base {
    foo: string;
}

interface SpecificA extends Base {
    bar: number;
}

interface SpecificB extends Base {
    baz: boolean;
}

enum TheType {
    A,
    B
}

interface A {
    type: TheType.A;
    foo: SpecificA[];
}

interface B {
    type: TheType.B;
    foo: SpecificB[];
}

type options = A | B;


// tests

function getValue(): options {
    return {
        type: TheType.A,
        foo: [
            {
                bar: 42,
                foo: 'my value'
            }
        ]
    };
}

const value = getValue();

value.foo.map(v => console.log(v.foo));

where the .map call is impossible, even though accessing v.foo should certainly be allowed.

I'll try to work around it by doing a bunch of type casting.

I encountered a scenario in which the requested behavior is undesirable:
```ts
interface Base {
type: string
}

interface Foo extends Base {
type: 'foo'
}
interface Bar extends Base {
type: 'bar'
}

type Foobar = Foo | Bar
type FoobarTypeProp = Foobar['type'] // should be 'foo' | 'bar', not string

Was this page helpful?
0 / 5 - 0 ratings

Related issues

remojansen picture remojansen  路  3Comments

MartynasZilinskas picture MartynasZilinskas  路  3Comments

bgrieder picture bgrieder  路  3Comments

DanielRosenwasser picture DanielRosenwasser  路  3Comments

dlaberge picture dlaberge  路  3Comments