How to achieve structural typesafety when target is given in curried function

Viewed 58

I am trying to achieve structural typesafety with a curried function where the first argument is the key of an object and the return curry function it's argument takes the target (object).

I have prepared a fully working playground, as it much easier to explain there: typescript playground

// I have a map of Resources where the keys are equal to the resource it's type

type Resources = {
  user: {
    id: string
    type: 'user'
    attributes: {
      name: string
      age: number
      last_name: string
    }
  },
  // uncomment this section to break function underneath

  //  usergroup: {
  //   id: string
  //   type: 'usergroup'
  //   attributes: {
  //     mosterd: string
  //     group: string
  //   }
  // }
}

type ResourceTypes = keyof Resources
// user|usergroup

type UserAttributes = keyof Resources['user']['attributes']
// name|age|last_name

function constrain<
  T extends { type: ResourceTypes },
  A extends keyof Resources[T['type']]['attributes']
> (attribute: A) {
  return (resource: T) => {

  }
}

// Is it possible to get typesafety for the attribute argument 
// when resource argument has been given, like following example?
const result = constrain('name')({ type: 'user' })

type AllAttributes = keyof Resources[ResourceTypes]['attributes']
// becomes never, I assume this is what is happening in the constrain function but why cant it infer the ResourceType?
2 Answers

Due to somewhat mysterious behavior of TypeScript, you'll have to construct a type for getting all keys of a union, using the answer to a related question.

EDIT: I've updated the generic constraints for the function so that the inner key (which is of AllAttributes type) matches the resource type.

type KeysOfUnion<T> = T extends T ? keyof T : never;
type NoKeys = keyof ({ a: 1 } | { b: 2 }); // never
type AllKeys = KeysOfUnion<{ a: 1 } | { b: 2 }>; // 'a' | 'b'

type AllAttributes = KeysOfUnion<Resources[ResourceTypes]['attributes']>;
// will now be 'name' | 'age' | 'last_name' | 'mosterd' | 'group'

function constrain<
  A extends KeysOfUnion<Resources[keyof Resources]['attributes']>,
  T extends {
    [x in keyof Resources]: Resources[x]['attributes'] extends {
      [y in A]: unknown;
    }
      ? x
      : never;
  }[keyof Resources]
>(attribute: A) {
  return (resource: { type: T }) => {};
}

constrain('name')({ type: 'user' }); // OK
constrain('name')({ type: 'usergroup' }); // error
constrain('group')({ type: 'usergroup' }); // OK
constrain('group')({ type: 'user' }); // error

You can also use UnionToIntersection<U> (credits to jcalz) and define your function as

function constrain<
  A extends keyof UnionToIntersection<Resources[keyof Resources]['attributes']>,
  T extends Extract<Resources[keyof Resources], { attributes: Record<A, any> }>,
  >(attribute: A) {
  return (resource: { type: T['type'] }) => {

  }
}

playground

Related