Search code examples
typescriptunion-types

Accessing property in union of object types fails for properties not defined on all union members


I am facing a scenario where a HTTP call's response varies according to region. I have specify return type of the object. So if I declare suppose 4 types and use union of these as a wrapper type.

Problem arises as there are fields which are not common to everything. Is the solution to this is to make those fields or optional. For me making a field optional means it is not necessary which is not true in this case . As this to make the Tslint error go Away.

Please tell me if you are failing to understand my question

EDIT:-

function mapAddress(address: AddressRegionXX | AddressRegionYY,region:string): AddressWithIdXX | AddressWithIdXX   {
  let addressId = address.id ? address.id : "XX";

  let addressType = addressId == "XX" ? "subbed" : "unsubbed";
 if(region == "XX"){
  return {
    firstName: address.first_name || null,
    lastName: address.last_name || null,
    street1: address.addr_1 || null,
    street2: address.addr_2 || null,
    city: address.city || null,
    state: address.state || null,
    postalCode: address.zip_code || null,
    phone: address.phone_number || null,
    addressId: addressId,
    addressType: addressType
  };
   if(region == "XX"){
  return {
    fName: address.f_name || null,
    lName: address.l_name || null,
    address: address.addr_1 || null,
    roomNo: address.addr_2 || null,
    district: address.district|| null,
    state: address.state || null,
    pinCode: address.zip_code || null,
    phoneNumber: address.phone_number || null,
    addressId: addressId,
    addressType: addressType
  };
 }
}

This is the context of where I have to use Union type Here the response depending on each region address type would change there is long list which is not practical to include here. As I have shown here field names varies for each region and for some additional fields are there . So what is the elegant way to tackle this situation is it proper to use conditional types . Is there any alternative to union type. As in the ened there would be atleast 5-6 address type and chance for more in future.

In layman terms 
is there any miraculous way in which :D 
We write something Like
type correctTypeAddress<T> =
    T extends Address? AddressXX :
    T extends Address? AddressYY :

mapAddress(address: AddressRegion,region:string):correctTypeAddress

Below is an example of what I am dealing with all types does not have same properties. So How to deal with non uniform type mappings Is there any alternative to using union type when

Way of recreating the problem

type typeA = {
  prop1:string;
  prop2:string;
}

type typeB = {
  prop1: string;
  prop3: string;
}
type typeC = {
  prop4: string;
  prop5: string;
}
type mappedType = typeA | typeB | typeC;

const a = (b): mappedType => {

  return {
    prop1:"1",
    prop5:"3"
  }
}

EDIT:- Applying conditional types but using generic leads to another lint error as Property 'prop1' does not exist on type 'T'

type typeIA = {
  prop1: string;
  prop2: string;
}

type typeIB = {
  prop1: string;
  prop3: string;
}
type typeIC = {
  prop4: string;
  prop5: string;
}

type typeOA = {
  prop1: string;
  prop2: string;
}

type typeOB = {
  prop1: string;
  prop3: string;
}
type typeOC = {
  prop4: string;
  prop5: string;
}
// type mappedType = typeA | typeB | typeC;

const a = <T extends typeIA | typeIB | typeIC>(_b: T): T extends typeIA ? typeOA : never | T extends typeIB ? typeOB : never | T extends typeIC ? typeOC : never=> {
  if (_b.prop1 == "1"){
   return {
     prop1: "1",
     prop3: "3"
   } as T extends typeIA ? typeOA : never | T extends typeIB ? typeOB : never | T extends typeIC ? typeOC : never
 }else{
    return {
      prop1: "1",
      prop2: "2"
    } as T extends typeIA ? typeOA : never | T extends typeIB ? typeOB : never | T extends typeIC ? typeOC : never
 }

}
const c = a({prop1:"1",prop2:"2"});

const d = a({ prop1: "1", prop3: "2" });

const e = a({ prop4: "1", prop5: "2" });

Solution

  • A full explanation of why this is the 'best' way (i joke there is no best way) probably is outside the scope of a stack overflow answer but essentially a quick few points would be:

    1. to get keyof union typesaftely you need to turn it into an intersection keyof (A | B) to keyof (A & B)

    2. Using 'Maybe' in which you can have two possible outcomes doubles as a solution for undefined/null, however in this case it solves that when picking from a union you could have 'Something' which will be typeA or typeB or nothing which is case typeC

    3. Conditional ReturnTypes are generally worse than overloading simply because the second you have a conditional return type you pretty much are sentencing yourself to have to cast in some way the return value.
    4. Read more about Scala's 'Option' or Haskells 'Maybe' of which this solution is based off somewhere else, is it's outside the scope of this answer but this solution basically stole its best ideas.

    Long code snippet incoming. Hope this helps this will feel excessive but it isn't.

    export type UnionToIntersection<U> = [U] extends [never]
        ? never
        : (U extends any ? (k: U) => void : never) extends ((k: infer I) => void)
            ? I
            : never;
    export type UnionMembersWith<T, K extends keyof UnionToIntersection<T>> = [T] extends [never]
        ? never
        : Exclude<T, Exclude<T, Partial<Record<K, any>>>>;
    
    export type Maybe<A> = _None<A> | _Some<A>;
    
    type PickReturn<A, K extends keyof UnionToIntersection<A>> = [K] extends [never]
        ? typeof None
        : [K] extends [keyof UnionMembersWith<A, K>]
            ? Maybe<NonNullable<UnionMembersWith<A, K>[K]>>
            : [K] extends [keyof A]
                ? Maybe<NonNullable<A[K]>>
                : typeof None
    
    
    class _Some<A> {
        readonly _tag: "some" = "some";
        readonly value: A;
        constructor(value: A) {
          this.value = value;
        }
        map<U>(f: (a: A) => U): Maybe<U> {
          return new _Some(f(this.value));
        }
    
        flatMap<B>(f: (a: A) => Maybe<B>): Maybe<B> {
          return f(this.value);
        }
    
        pick<K extends keyof UnionToIntersection<A>>(key: K): PickReturn<A, K> {
          return Maybe((this.value as any)[key]) as any;
        }
    
        get get(): A | undefined {
            return this.value;
        }
    }
    
    class _None<A = never> {
        static value: Maybe<never> = new _None();
        readonly _tag = "none";
    
        map<U>(f: (a: A) => U): Maybe<U> {
            return this as any;
        }
    
        flatMap<B>(f: (a: A) => Maybe<B>): Maybe<B> {
            return this as any;
        }
    
        pick<K extends keyof UnionToIntersection<A>>(key: K): PickReturn<A, K> {
            return this as any;
        }
    
        get get(): A | undefined {
            return undefined;
        }
    
        getOrElse(none: never[]): [A] extends [Array<any>] ? A : A | never[];
        getOrElse<B>(none: B): A | B;
        getOrElse<B>(none: B): A | B {
            return none as any;
        }
    }
    
    export const None: Maybe<never> = _None.value;
    export const Some = <A>(a: A): _Some<A> => new _Some(a);
    
    export function Maybe<A>(value: A | null | undefined): Maybe<A> {
        if (value !== null && value !== undefined) return Some(value);
        return None;
    }
    
    //* END IMPLEMNTATION */
    
    
    type typeIA = {
      prop1: string;
      prop2: string;
    }
    
    type typeIB = {
      prop1: string;
      prop3: string;
    }
    type typeIC = {
      prop4: string;
      prop5: string;
    }
    
    type typeOA = {
      prop1: string;
      prop2: string;
    }
    
    type typeOB = {
      prop1: string;
      prop3: string;
    }
    type typeOC = {
      prop4: string;
      prop5: string;
    }
    // type mappedType = typeA | typeB | typeC;
    
    function a(_b: typeIC): typeOC
    function a(_b: typeIB): typeOB
    function a(_b: typeIA): typeOA
    function a(_b: typeIA | typeIB | typeIC): typeOA | typeOB | typeOC {
        /* 100% typesafe */
      if (Maybe(_b).pick("prop1").get === "1"){
       return {
         prop1: "1",
         prop3: "3"
       }
     }else{
        return {
          prop1: "1",
          prop2: "2"
        }
     }
    
    }
    
    const c = a({prop1:"1",prop2:"2"}); // type oA
    const d = a({ prop1: "1", prop3: "2" }); // type oB
    const e = a({ prop4: "1", prop5: "2" }); // type oC
    

    EDIT: "The Know More": Maybe isn't meant to be a solution to this one off problem for you but a solution to a certain type of 'effect' that can occur in programming. Maybe is monadic and monads trap effects, the effect Maybe traps is non-determinism and the idea here is that Maybe abstracts it away so you don't have to think about it.

    The point here can be easily missed because any other overflow'er will claim that this can easily be solved by nesting 'If/Else''s but the idea of Maybe is that it's an abstraction in which you no longer need to check if things ARE or AREN"T there and therefore no longer need If/Else

    so that's alot of words what does that look like? So this code is both runtime and type-level safe.

    interface IPerson {
        name: string;
        children: IPerson[] | undefined;
    }
    
    const person = {
        name: "Sally",
        children: [
            {
                name: "Billy",
                children: [
                    {
                        name: "Suzie",
                        children: undefined
                    }
                ]
            }
        ]
    };
    
    const test = Maybe(person).pick("children").pick(0).pick("children").pick(0).get;  // Typesafe / Runtime safe possible path
    const test = Maybe(person).pick("children").pick(0).pick("children").pick(10000).get ; // Typesafe / Runtime safe impossible paths
    
    /* We have 'Children' which is non-deterministic it could be undefined OR it could be defined */
    /* Let's wrap person in Maybe so we don't care whther its there or not there anymore */
    const test2 = Maybe(person).pick("children").map((childrenArr) => {
        return childrenArr.map((child) => child.name.toUpperCase())
    }).getOrElse([]);  // string[] notice how when using things in the context of our Maybe we cease to care about undefined.
    
    
    const test3 = Maybe(person).pick("children").pick(10000).map((person) => {
        return {...person, name: person.name.toUpperCase()} // safe even though there's no person at Array index 10000
    }).getOrElse({name: "John Doe", children: []})   // IPerson even though there is no person at Array index 10000