2

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 Answers2

2

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
T.D. Stoneheart
  • 811
  • 4
  • 7
  • Thanks for responding. Although the function now doesnt give an error, it's not a solution as it will now also accept properties for types that do not have that property. eg. You can now add usergroup resource properties to user resource... – Michiel de Vos Aug 25 '21 at 13:30
  • I've edited the answer. Please check if my updated solution works for you. – T.D. Stoneheart Aug 25 '21 at 14:00
  • 1
    Yes, it does. thats quite clever, you've basically inversed the type checking flow. Very insightful. Thank you very much. – Michiel de Vos Aug 25 '21 at 14:10
2

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

Teneff
  • 30,564
  • 13
  • 72
  • 103