3

I have a situation where I have an array of generic items (Item), and within the item itself, I want the generic parameter to be inferred and specific.

That is, I want have an array of generic items, but each one can have a different generic typing, and that should be retained.

type Item<T> = {
  value: T; 
  fn: (value: T) => void; 
}


function usesItem<T>(item: Item<T>) {

}


// This is fine - the value is inferred
usesItem({
  value: 999, 
  fn: (value) => {
      //value is inferred as number
  }
})

First approach, declare an Array<Item<unknown>:

function usesItems1(items: Array<Item<unknown>>) {

}


usesItems1([{
  value: 999, 
  fn: (value) => {
    // value is unknown - we want it to be number
  }
}
])

Second approach: introduce the generic from the function:

function usesItems2<T>(items: Array<Item<T>>) {

}

// appears to work...
usesItems2([{
  value: 999, 
  fn: (value) => {
    // value is number
  }
}
])

// ...but it doesn't really
usesItems2([{
  value: 999, 
  fn: (value) => {
    // value is number
  }
}, {
  // Type 'string' is not assignable to type 'number'.(2322)
  value: "aaa", 
  fn: (value) => {

  }
}
]); 

Third approach - use the infer keyword, I tried a couple of approaches:

type InferredItem1<T>  = T extends Item<infer R> ? Item<R> : never; 

function usesItems3(items: Array<InferredItem1<unknown>>) {

}

usesItems3([{
  //Type 'number' is not assignable to type 'never'.(2322)

  value: 999, 
  fn: (value) => {
  }
}
]); 



type InferredItem2<T extends Item<unknown>>  = T extends Item<infer R> ? Item<R> : never; 

function usesItems3b(items: Array<InferredItem2<Item<unknown>>>) {

}

usesItems3b([{
  value: 999, 
  fn: (value) => {
    // value is unknown
  }
}
]); 

TypeScript Playground

How can I achieve what I want?

Or, if this isn't possible I guess a canonical reference to the Github issue/similar that gets into it.

dwjohnston
  • 11,163
  • 32
  • 99
  • 194

3 Answers3

2

Well, I have something that works, but it's weird and hacky, and I don't know if I'm right about why it works. The first part is straightforward: the createRoutes function needs to be generic so that there is a type parameter to infer at all:

function createRoutes<T>(routes: Route<T>[]) {
  // ...
}

Here comes the weird part: requestBodyValidator needs to be written as a method instead of an arrow function, but createHandler needs to stay as an arrow function:

createRoutes([{
  route: "/a",
  method: "get",
  // method, not arrow function
  requestBodyValidator(item): Bar {
    return item as Bar;
  },
  // arrow function
  createHandler: () => {
    // value is inferred as Bar 
    return (value) => {}
  }
}])

Playground Link

My best guess for why this works is that by making requestBodyValidator a method, its type is considered when inferring the type of the whole object, because Typescript treats a method as "part of the object" in that sense. Then because createHandler is an arrow function, Typescript treats it like a sub-expression and its type is inferred from context. Because this sub-expression doesn't have a known type while the object's type is being inferred, then its type can't be used to infer the object's type. That's my best guess, anyway; maybe the reason is something else.

kaya3
  • 47,440
  • 4
  • 68
  • 97
1

Let me know if it works for you:


type Handler<T> = (value: T) => void;

type Methods = "post" | "get" | "put" | "patch" | "delete"

export type Route<T> = {
  methods: Methods,
  requestBodyValidator: (item: unknown) => T;
  createHandler: () => Handler<T>;
}

type CreateHandler<T> = { createHandler: () => Handler<T>; }

type Iterate<T extends ReadonlyArray<Route<any>>, Result extends any[] = []> =
  (T extends []
    ? Result
    : (T extends [infer H, ...infer Tail]
      ? (H extends Route<infer E>
        ? Tail extends ReadonlyArray<Route<any>>
        ? Iterate<Tail, [...Result, Omit<H, 'createHandler'> & CreateHandler<E>]>
        : never
        : never)
      : T)
  )

function createRoutes<
  Type,
  RouteElem extends Route<Type>,
  Routes extends RouteElem[],
  >(routes: Iterate<[...Routes]>) {

}

type Bar = {
  bar: string;
}
type Foo = {
  foo: string;
}

createRoutes([{
  methods: 'get',
  requestBodyValidator(item: unknown): Bar {
    return item as Bar;
  },
  createHandler: () => {

    // I want value to be inferred as Bar 
    return (value /** Bar */) => {

    }
  }
}, {
  methods: 'post',
  requestBodyValidator(item: unknown): Foo {
    return item as Foo;
  },
  createHandler: () => {

    return (value /** Foo */) => {

    }
  }
}])

Playground

P.S. I have shamelesly stolen an idea from @kaya regarding using methods instead of arrow functions.

I was not able to make it work with arrows

1

What you are looking for are existential types. While TypeScript does not currently support existential types directly(nor do most languages with generic types), there are indirect ways to get it done. There has been this discussion on it on the TypeScript GitHub repo, but it hasn't seen much activity in the last year.

The thing is generics in TypeScript are universal, meaning if I define a function as <T>function(i: T) {...} it has to work for all possibles types T.

Arrays like the ones you need require existential generic types. This means that for the same function <T>function(i: T) {...}, the function definition decides a set of types it works for, and when you call the function, you need to account for all possible types. Yeah, it is a bit hard to wrap your head around.

The above text in italics is a paraphrasis from this post by jcalz.


You can implement existential generics in TypeScript with callbacks. Here's your code, rewritten to allow your heterogeneous array.

This is your Item type:

type Item<T> = {
  value: T; 
  fn: (value: T) => void; 
}

Now we make an existential generic version for Item:

type ExistentialItem = <U>(callback: <T>(item: Item<T>) => U) => U;

No ExistentialItem is the type of function that takes a callback and returns the result of the callback. The callback takes an Item of type T and returns a result. Note that the type of ExistentialItem does not depend on the type of Item anymore. You'll want to convert all your Items to ExistentialItems, so write a helper for that.


const getExistentialItem = <T>(item: Item<T>): ExistentialItem => {
    return (callback) => callback(item);
}

// The compiled JS for the sake of simplicity
const getExistentialItem = (i) => {
    return (cb) => cb(i);
};

Now you can use the ExistentialItem to type your heterogeneous array as:


   const arr: ExistentialItem[] = [
    getItem({
        value: 1,
        fn: (n) => {
            console.log(n.toString())
        }
    }),
    getItem({
        value: '1',
        fn: (n) => {
            console.log(n.padStart(10, '1'))
        }
    }),

]

All the type checks pass for this array. The caveat is that using this ExistentialItem array is significantly more work, at least in my opinion.

Let's say you want your useItems to return an array of all the keys of the objects in the array. Instead of writing this like you normally would if it was a homogenous array:

const useItems = (items: Item<number>[]) => {
    return items.map(i => i.key)
}

You have to first call the ExistentialItem.

const useItems = (items: ExistentialItem[]) => {
    return items.map(cb => cb(i => i.key))
}

Here we are passing the callback, in which we decide what to do with the item, instead of just using the item directly.

It's up to you to decide whether this added code complexity is worth having type-checking on heterogeneous arrays. If you know the size of the array, you can use [Item<number>, Item<string>], which is an ok solution, but then pushing to the array or trying to access indices greater than 2 will cause issues. Or you can use any, and use type assertions to get type checking again. Not a good solution IMO.

So let's just hope that existential types are implemented into TS natively at some point.

The whole code again, just for the sake of completion:

type Item<T> = {
    key: T
    fn: (val: T) => void
}

type ExistentialItem = <R>(cb: <T>(item: Item<T>) => R) => R;

const getExistentialItem = <T,>(i: Item<T>): ExistentialItem => {
    return (cb) => cb(i);
}

const arr: ExistentialItem[] = [
    getExistentialItem({
        key: 1,
        fn: (n) => {
            console.log(n.toString())
        }
    }),
    getExistentialItem({
        key: '1',
        fn: (n) => {
            console.log(n.padStart(10, '1'))
        }
    }),
]

const useItems = (items: ExistentialItem[]) => {
    return items.map(item => item(i => i.key))
}

Check also this answer and this answer to get a better explanation of universal and existential types.

blackgreen
  • 34,072
  • 23
  • 111
  • 129
nullptr
  • 3,701
  • 2
  • 16
  • 40