TypeScript: Constraint for an object's property to be an array by generic key

168 views Asked by At

Is there a way to write this function such that there is no type casting?

function insert<O extends object, K extends keyof O>(parent: O, key: K) {
  type Item = O[K] extends Array<infer E> ? E : never;
  return (entry: Item) => (key in parent)
    ? (parent[key] as Item[]).push(entry)
    : (parent[key] as Item[]) = [entry];
}

I would like to add a constraint, so that O[K] is assumed to be an array inside of the function's body. Object-key combinations which don't satisfy this constraint should not typecheck.

Edit

Based on the answers and my own experimentation I managed to bring it closer to having no casts:

function insert<I, K extends keyof O, O extends { [key in K]?: I[]; }>(parent: O, key: K, item: I) {
  (key in parent)
    ? parent[key].push(item)
    : parent[key] = [item] as O[K];
}

However, I still have to use a single cast as O[K], otherwise I get the following error:

'I[]' is assignable to the constraint of type 'O[K]', but 'O[K]' could be instantiated with a different subtype of constraint 'I[]'. ts(2322)

Edit 2:

I've prepared a TypeScript Playground with some example test cases, to make my question less confusing: playground

3

There are 3 answers

0
Bahadir Tasdemir On

You can achieve what you trying to do via Maps in typescript. You can easily convert the maps to JSON objects whenever needed

function insert<T, K>(parent: Map<T, Array<K>>, key: T) {
  return (entry: K) => (parent.has(key))
    ? (parent.get(key) as K[]).push(entry)
    : parent.set(key, [entry]);
}

const parentObj = new Map<string, Array<string>>([
    ["foo", ["bar", "bas"]]
])

insert(parentObj, "kal")("tar")

console.log(JSON.stringify(Object.fromEntries(parentObj)))
0
paydro On

You can avoid type casting by using type assertion in its place.

function insert<O extends object, K extends keyof O>(parent: O, key: K) {
  type Item = O[K] extends Array<infer E> ? E : never;
  return (entry: Item) => {
    if (key in parent) {
      (parent[key] as Item[]).push(entry);
    } else {
      (parent[key] as unknown) = [entry] as Item[];
    }
  };
}

Here, instead of using (parent[key] as Item[]) = [entry], we use (parent[key] as unknown) = [entry] as Item[] to perform a type assertion without type casting. This way, the type of parent[key] is inferred inherently.

0
Dimava On
function insert<O extends Partial<Record<K, any[]>>, K extends keyof O>(parent: O, key: K) {
  type Item = NonNullable<O[K]>[number];
  return (entry: Item) => (parent[key] ??= ([] as any[] as NonNullable<O[K]>)).push(entry)
}

insert({ foo: [1], bar: 'a' }, 'foo')(1) // ok
insert({ foo: [1], bar: 'a' }, 'foo')('a') // err
insert({ foo: [1], bar: 'a' }, 'bar')(1) // err