2

I was developing a typescript project where I came across a situation that I'd like to get type inference and completion for the second argument data of a function which depends on the first argument type of the function.

Here is the example type

enum MessageType {
  FOO = "FOO",
  BAR = "BAR",
  BAZ = "BAZ",
}

interface DataFoo {
  type: MessageType.FOO
  payload: string
}

interface DataBarOrBaz {
  type: MessageType.BAR | MessageType.BAZ
  name: string
  age: number
}

type Data = DataFoo | DataBarOrBaz

This is the function I am developing and I would like to have autocomplete for the data parameter

function emit<T extends MessageType>(type: T, data: ???) {}

The expected result is like this

emit(MessageType.FOO, {
  payload: "hello world"
  // with autocomplete for payload in DataFoo
})

emit(MessageType.BAR, {
  name: "hello"
  age: 18
  // with autocomplete for name and age in DataBarOrBaz
})

emit(MessageType.BAZ, { 
  name: "hello",
  age: 18
  // with autocomplete for name and age in DataBarOrBaz
})

However when I try to achieve the intended result with the Extract utility type in typescript, it doesn't work for the interfaces that the type field is a union of MessageType

function emit<T extends MessageType>(type: T, data: Omit<Extract<Data, {type: T}>, "type">) {}
emit(MessageType.FOO, { // the type for data becomes Omit<DataFoo, "type">
  payload: "hello world" 
  // works as intended
})

emit(MessageType.BAR, { // the type for data becomes Omit<never, "type">
// expecting the type to be Omit<DataBarOrBaz, "type">
})

emit(MessageType.BAZ, { // the type for data becomes Omit<never, "type">
// expecting the type to be Omit<DataBarOrBaz, "type">
})

Is there any way to achieve the goal using typescript v5?

Typescript Playground

Leo Wong
  • 23
  • 2
  • 2
    Does [this approach](https://tsplay.dev/Nda1Qm) work for you? If yes, I'll write an answer explaining; If not, what am I missing? – wonderflame Jul 25 '23 at 18:12
  • Similar approach: https://www.typescriptlang.org/play?#code/KYOwrgtgBAssDO8CGBzYAVAngB2FA3gFBRQBiA8uVALxQBEF5dANMVAEICCASjfV9xZsuALT51RQgL6FCASxAAXYACcAZkgDGeACJJFSUgHsjBNopzAAXLATI0WXADpGbbEkwAbI0gAmN+EUVBRRCGXklVQ1tKD0DdiQVchUEgC8zEgtcGzhEVAxLJwEoAB9bPIdC0TYQJAhrKEDgkFCSfJtwCAAjVTDZLN19JD44wxNS2KGEpJSkVP7LcvsC3HQjUb4iEgBtXOXHYBdKAF0bUeMjNl27fIOinlPJ+MTktKu926rOEUfR6de5n1CGowCBNIo5EYQFBgBA5IoADzoGEAD2UIF88CgAGtgJgjGolp9VushgA+AAUAxs6GYUF8Qxs5DhiI+lRJo226GOdIA5ANeWSAJQEcKw+EUtkrQ6MOlbKDuLw+fz0AAWwE83igAHcjCpPL46GEhbJxYpJTd2YcBHKanUGnR1ZqjEI2mgbABGAAcxtNLItFWl9xEcqgdvqNkdGu8rqg7Sg3uNQA – Lesiak Jul 25 '23 at 18:26
  • @wonderflame, yes it is working! But I don't understand this line `K in Data as T extends K["type"]`. Can you explain more about it? Thanks. – Leo Wong Jul 26 '23 at 10:12
  • sure, will do soon in the answer – wonderflame Jul 26 '23 at 10:17

1 Answers1

1

Your approach is actually correct, however, the problem is with Extract. To be more precise, the problem is with the way Extract checks whether T extends the other type:

/**
 * Extract from T those types that are assignable to U
 */
type Extract<T, U> = T extends U ? T : never;

The problem in this condition is that T is a union in the case with DataBarOrBaz:

interface DataBarOrBaz {
  type: MessageType.BAR | MessageType.BAZ;
  name: string;
  age: number;
}

So, when we actually pass MessageType.BAZ to the emit, we get this condition:

// false
type Case1 = MessageType.BAR | MessageType.BAZ extends MessageType.BAZ ? true : false

Which is obviously false, since the left part is less specific than the right part, which makes the right part of the condition a subset of the left, since it is more specific.

So, to fix the issue we should reverse the condition, to:

// true
type Case2 = MessageType.BAZ extends MessageType.BAR | MessageType.BAZ ? true : false

To achieve this, we will use mapped types to map through every version of the message, and by using the key remapping we are going to add the condition that was mentioned previosly. If the type matches the condition then we make key to be message.type, just to make the mapped type valid, since we can only use string | number | symbol as keys in the objects, though we won't actually need the keys, only values. In the value we will just omit type from the message:

type FindData<T extends MessageType> = {
  [K in Data as T extends K['type'] ? K['type'] : never]: Omit<K, 'type'>;
}

Testing:

// type Result = {
//     BAR: Omit<DataBarOrBaz, "type">;
//     BAZ: Omit<DataBarOrBaz, "type">;
// }
type Result = FindData<MessageType.BAR>

The values looks good and to extract them, we will use the ValueOf described in here:

type ValueOf<T> = T[keyof T];

type FindData<T extends MessageType> = ValueOf<{
  [K in Data as T extends K['type'] ? K['type'] : never]: Omit<K, 'type'>;
}>;

// type Result = {
//     name: string;
//     age: number;
// }
type Result = FindData<MessageType.BAR>;

Usage:

function emit<T extends MessageType>(type: T, data: FindData<T>) {}

emit(MessageType.FOO, {
  payload: 'hello world',
});

emit(MessageType.BAR, {
  name: 'hello',
  age: 18,
});

emit(MessageType.BAZ, {
  name: 'hello',
  age: 18,
});

playground

wonderflame
  • 6,759
  • 1
  • 1
  • 17