Is is possible to split a generic type?

Viewed 712

API endpoints that I call deliver data in the following shapes:

interface ArrayResponse<T> {
   data: Partial<T>[]
}
interface ObjectResponse<T> {
   data: Partial<T>
}

I've written a function that calls endpoints and returns responses:

function fetchOne<T>(path: string): ObjectResponse<T> {
  // HTTP call
}

function fetchMany<T>(path: string): ArrayResponse<T> {
  // Exact same HTTP logic as above
}

Ideally I want to have a single response type:

interface Response<T> {
  data: Partial<T> | Partial<T>[]
}

But I what I really want is to be able to have the above type to determine Partial or Partial[] based on the value passed for T.

So Response<T> would result in data: Partial<T> and Response<T>[] results in data: Partial<T>[].

I'm also open to other creative ways of achieving the same effect.

3 Answers

You can do that using User-Defined Type Guards

so, if you have these interfaces:

interface ObjectResponse<T> {
  data: Partial<T>
}

interface ArrayResponse<T> {
  data: Partial<T>[]
}

type IResponse<T> = ObjectResponse<T> | ArrayResponse<T>;

You can write this guard function:

function isMultiResponse<T>(response: IResponse<T>): response is ArrayResponse<T> {
  return Array.isArray(response.data);
}

This function will get a type of IResponse<T>, and determines if it's an ArrayResponse<T>. This will allow you to check at runtime what kind of type it is.

This translates to regular javascript, but typescript knows that this function determines the type.

So, for your two functions, you can combine them if the logic is the same:

async function fetchData<T>(path: string): Promise<IResponse<T>> {
  // HTTP call
  return get(path);
}

And when you use the user defined guard function, typescript will know which type the response is:

async function doSomething<T>(): Promise<void> {
  const path = '';
  const res: IResponse<T> = await fetchData(path);

  if (isMultiResponse(res)) {
    // typescript will know the response is ArrayResponse<T>
    console.log(res.data[0]);
  } else {
    // typescript will know the response is ObjectResponse<T>
    console.log(res.data);
  }
}

VS Code in action


the first type is Partial<T>[] as expected vscode detects this as a T[]


the second type is Partial<T> as expected vscode detects this as T

Compiled code

When compiled to JavaScript, the isMultiResponse function and if statement will obviously be there. But you probably want a check like that somewhere in your code anyway :-) Writing it using a Type Guard will do the necessary check at runtime, but will also make typescript aware of the two different scenarios and give you feedback accordingly.

It can be done. But regardless of how you set this up you would need to manually set the generic T when calling, like fetch<MyType>(path), in order to get a meaningful response type because the generic T cannot possibly be inferred from the path which is a string.

Try this:

function fetchEither<T>(path: string): T extends (infer U)[] ? ArrayResponse<U> : ObjectResponse<T> {   
}

With this setup, you would pass in MyType as T to get a single response or MyType[] as T for a multiple response. (note: this assumes that MyType itself would not be an array)

const array = fetchEither<MyType[]>(""); // has type ArrayResponse<MyType>
const object = fetchEither<MyType>(""); // has type ObjectResponse<MyType>

Typescript Playground Link

Given that T cannot be inferred, it might make more sense to just return a union type and determine the value at runtime as proposed by @Thatkookooguy.

Maybe someone else can think outside the box better than I, but what you're asking for doesn't seem possible. Posting this answer for the sake of discussion. Let's try a simpler example. What you have right now:

function returnString(): string {
  // returns string
}

function returnNumber(): number {
  // returns number
}

let string: string = returnString();
let number: number = returnNumber();

Now let's say you try to add a union type. Notice that regardless of what happens inside your function at runtime, the compiler can't infer the appropriate type:

type StringOrNumber = string | number;

function returnString(): StringOrNumber {
  // returns string
}

function returnNumber(): StringOrNumber {
  // returns number
}

let string: string = returnString();
// Type 'StringOrNumber' is not assignable to type 'string'.
//  Type 'number' is not assignable to type 'string'.

let number: number = returnNumber();
// Type 'StringOrNumber' is not assignable to type 'number'.
//  Type 'string' is not assignable to type 'number'.
Related