1

Lets say i have an interface Animal, and i want it to have some general properties, and then either be a cat or a dog and have corresponding properties.

interface Dog {
    dog: { sound: string; }
}

interface Cat {
    cat: { lives: number; }
}

type CatOrDog = Cat | Dog;

interface Animal {
    weight: number;
    // index type of CatOrDog
}

So i was thinking

interface Animal {
   weight: number;
   [K in keyof CatOrDog]: CatOrDog[K];
}

But TypeScript gets very angry when i use anything else than the [K:string]: type

What i am trying to achieve is

// Success
const dog = <Animal> {
    weight: 5,
    dog: {sound: "woof" }
}

// Error, lives doesn't exist on Dog
const errorAnimal = <Animal> {
    weight: 5,
    dog: {sound: "woof" },
    cat: { lives: 9 }
}

Also, if i wanted to add more index types, would that be possible?

Pavlo
  • 1,157
  • 7
  • 13

2 Answers2

1

Unions like Cat | Dog are inclusive, meaning that something is a Cat | Dog if it is a Cat or a Dog or both. TypeScript doesn't have a general exclusive union operator. If your unions share a common property with distinct values, you can use discriminated unions like @MateuszKocz suggests. Otherwise, you could build your own Xor type function for objects:

type ProhibitKeys<K extends keyof any> = { [P in K]?: never }

type Xor<T, U> = (T & ProhibitKeys<Exclude<keyof U, keyof T>>) |
  (U & ProhibitKeys<Exclude<keyof T, keyof U>>);

Then you can define Animal as the exclusive union of Cat and Dog, intersected with the additional properties common to all Animals:

type Animal = Xor<Cat, Dog> & { weight: number };

Now you can get your desired behavior (type annotations are superior to type assertions so I am using those here):

// Success
const dog: Animal = {
  weight: 5,
  dog: { sound: "woof" }
}

// Error, {lives: number} not assignable to undefined
const errorAnimal: Animal = {
  weight: 5,
  dog: { sound: "woof" },
  cat: { lives: 9 }
}

Hope that helps; good luck!

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • I was hoping for an `interface`, but i see that it can't be expressed properly. I figured out how to do more than two elements Xor> – Pavlo Feb 20 '19 at 18:16
  • 1
    It's also possible to turn an inclusive union into an exclusive one (`type AllKeys = T extends any ? keyof T : never; type MultiXor = [T] extends [infer U] ? U extends any ? U & ProhibitKeys, keyof U>> : never : never);)` And then you can use `MultiXor` instead of `Xor>` but whichever way works for you. – jcalz Feb 21 '19 at 00:19
  • I've been trying to solve this problem for a couple of hours now using `T extends any[]` but ended up having 4 Xors, and the first and last element were possible. Advanced types are quite scary, but it's a very small language that works be great to have in my arsenal – Pavlo Feb 21 '19 at 00:32
0

If you are willing to change your code a bit, then tagged unions will be the answer you're looking for.

interface CommonAnimal {
  weight: number
}

interface Dog extends CommonAnimal {
  // This is the important part. `type` a tag used by TS to recognise this type.
  type: 'dog'
  sound: string
}

interface Cat extends CommonAnimal {
  type: 'cat'
  lives: number
}

type Animal = Dog | Cat

const dog: Animal = {
  type: 'dog',
  weight: 10,
  sound: 'woof'
}

const cat: Animal = {
  type: 'cat',
  weight: 5,
  lives: 9
}

const robot: Animal = {
  type: 'robot' // error
}

That way you'll be able to keep values on one level, without nesting, while satisfying TS's type recognition.

Mateusz Kocz
  • 4,492
  • 1
  • 25
  • 27
  • This is not possible. I am generating TypeScript interfaces based on an xsd schema, where i want to convert into indexed types. So the properties have to be `cat` and `dog` right now it ends up being a bunch of optional properties, and no way to tell the structure without looking at the schema – Pavlo Feb 18 '19 at 19:57