mpen
mpen

Reputation: 282845

TypeScript: Suppress "Property does not exist on type" for union types

interface AddressBookingStop {
    street: string
    placeName: string
    city: string
    province: string
    postalCode: string
}

interface ShowBookingStop {
    id: number;
    city: string;
    postal_code: string;
    place_name: string;
    province: string;
    street: string;
    segment_id: number;
    order: number;
}

function formatAddress(stop: AddressBookingStop|ShowBookingStop): string {
    let address = [stop.street || stop.placeName || stop.place_name, stop.city, stop.province].filter(s => s).join(', ');
    const postalCode = stop.postalCode || stop.postal_code;
    if (postalCode) {
        address += ` ${postalCode}`;
    }
    return address;
}

playground

TypeScript is complaining:

enter image description here

But I have stop.placeName || stop.place_name there, so one of those two properties is guaranteed to exist.

Is there any way to make TS understand this?

Upvotes: 4

Views: 762

Answers (2)

This line of code AddressBookingStop|ShowBookingStop means that TS, by the default, will allow you to use only those properties which are common for both interfaces. THis is safe behavior. enter image description here

COnsider this example:

type A = {
    tag: string,
    a: 'a'
}

type B = {
    tag: string,
    b: 'b'
}

type C = keyof (A | B) // tag

Nevertheless, there is always a workaround in TypeScript type system :D YOu can add optional property with never type:

type A = {
    tag: string,
    a: 'a'
    b?: never
}

type B = {
    tag: string,
    b: 'b',
    a?: never
}


//"tag" | "a" | "b"
type C = keyof (A | B)

declare var b: B
if (b.a) {
    const x = b.a // never
}

You might have noticed that in last example all properties are allowed in a type scope. Don't worry, using disallowed prop b.a is impossible because it has never type.

Let's go back to our example:

interface AddressBookingStop {
    street: string
    placeName: string
    city: string
    province: string
    postalCode: string
}

interface ShowBookingStop {
    id: number;
    city: string;
    postal_code: string;
    place_name: string;
    province: string;
    street: string;
    segment_id: number;
    order: number;
}

type UnionKeys<T> = T extends T ? keyof T : never;

// credits goes to https://stackoverflow.com/questions/65805600/type-union-not-checking-for-excess-properties#answer-65805753
type StrictUnionHelper<T, TAll> =
    T extends any
    ? T & Partial<Record<Exclude<UnionKeys<TAll>, keyof T>, never>> : never;

type StrictUnion<T> = StrictUnionHelper<T, T>



function formatAddress(stop: StrictUnion<AddressBookingStop | ShowBookingStop>): string {
    let address = [stop.street || stop.placeName || stop.place_name, stop.city, stop.province].filter(s => s).join(', ');
    const postalCode = stop.postalCode || stop.postal_code;
    if (postalCode) {
        address += ` ${postalCode}`;
    }
    return address;
}

Playground

More functional approach:


const PROPS = ['street', 'placeName', 'place_name', 'city', 'province'] as const

const computeAddress = (stop: StrictUnion<AddressBookingStop | ShowBookingStop>) =>
    PROPS.reduce((acc, elem) =>
        stop[elem] ? `${acc}, ${stop[elem]}` : acc, ''
    )

const withPostalCode = (address: string) =>
    (stop: StrictUnion<AddressBookingStop | ShowBookingStop>) => {
        const postalCode = stop.postalCode || stop.postal_code;

        return postalCode ? `${address} ${postalCode}` : address
    }


const formatAddress = (stop: StrictUnion<AddressBookingStop | ShowBookingStop>) =>
    withPostalCode(computeAddress(stop))(stop)

Playground

Upvotes: 2

geisterfurz007
geisterfurz007

Reputation: 5874

TypeScript does not allow you to access properties of a value that aren't 100% there.

If you just want to suppress all warnings on that line, you can use the // @ts-ignore comment introduced in TS 2.6.

If you want a little less bruteforce method of silencing the compiler, you can pretend to have information you don't really have by casting to the respective types:

function formatAddress(stop: AddressBookingStop|ShowBookingStop): string {
    let address = [stop.street || (stop as AddressBookingStop).placeName || (stop as ShowBookingStop).place_name, stop.city, stop.province].filter(s => s).join(', ');
    const postalCode = (stop as AddressBookingStop).postalCode || (stop as ShowBookingStop).postal_code;
    if (postalCode) {
        address += ` ${postalCode}`;
    }
    return address;
}

If you want to do make the compiler super happy without cheating (but at the cost of some runtime logic, sadly), you can use a manual typeguard like this:


function isShowBookingStop(stop: AddressBookingStop|ShowBookingStop): stop is ShowBookingStop {
    return Boolean((stop as ShowBookingStop).id);
}

function formatAddress(stop: AddressBookingStop|ShowBookingStop): string {
    const street = stop.street || (isShowBookingStop(stop) ? stop.place_name : stop.placeName);
    let address = [street, stop.city, stop.province].filter(s => s).join(', ');
    const postalCode = isShowBookingStop(stop) ? stop.postal_code : stop.postalCode;
    if (postalCode) {
        address += ` ${postalCode}`;
    }
    return address;
}

The return value of stop is ShowBookingStop tells the compiler that if the returned value is true, the input was of type ShowBookingStop. You can then use that information in the formatAddress function to determine which property to use. Note that you will have auto-completion in the falsy branch of the ternary operator as well because the compiler understands that if the stop is not a ShowBookingStop, it has to be an AddressBookingStop.

Which of these options you use, is entirely up to you!

Upvotes: 3

Related Questions