6

Example from https://www.typescriptlang.org/docs/handbook/advanced-types.html

function getProperty<T, K extends keyof T>(o: T, name: K): T[K] {
    return o[name]; // o[name] is of type T[K]
}

Curried version:

function curriedGetProperty<T, K extends keyof T>(name: K): (o: T) => T[K] {
    return (o: T) => o[name]; // o[name] is of type T[K]
}

const record = { id: 4, label: 'hello' }

const getId = curriedGetProperty('id') // Argument of type '"id"' is not assignable to parameter of type 'never'.

const id = getId(record)
chautelly
  • 447
  • 3
  • 14

4 Answers4

3

Edit for TypeScript >= 4.1.5

const makeGetter = <TKey extends string>(key: TKey) => <TObject extends { [P in TKey]?: unknown }>(object: TKey extends keyof TObject ? TObject : `${TKey} is missing as property of object`) => (object as TObject)[key];

const getId = makeGetter('id');

const a: unknown = getId({})
const b: number = getId({id: 1})
const c: number | undefined = getId({} as { id?: number})

The compiler will complain about getId({}) with a helpful error message.


Using TypeScript 3.0.3 I was able to do this:

function composeGetter<K extends string>(prop: K) {
    function getter<T extends { [P in K]?: any }>(object: T): T[typeof prop]
    function getter<T extends { [P in K]: any }>(object: T) {
        return object[prop]
    }

    return getter
}
chautelly
  • 447
  • 3
  • 14
1
const getProperty = <P extends string>(prop: P) => <O extends any>(obj: O) => obj[prop]

const record = { id: 4, label: 'hello' }

const getId = getProperty('id')

const id = getId(record)

This seems to work. The type for id is inferred properly as a number. Only thing is you'll receive any if the object passed into getId doesn't have an id property on it, so it's not strict, but an overall elegant solution.

EDIT: Since writing this answer, I've learned that the Record type can be used to specify a type of object which requires a specific key. Using this knowledge, we can write a typesafe, succinct, readable solution:

// implementation
const get = <K extends string>(key: K) => <V>(obj: Record<K, V>) => obj[key]

// usage
const person = {
  name: "kingdaro",
  age: 21,
}

const fruit = {
  type: "apple",
  color: "red",
}

const nameGetter = get("name")

nameGetter(person) // return type inferred as string
nameGetter(fruit) // fails, fruit has no key "name"

// minor caveat: when passing an object literal, the extra key will raise an error
// you can declare the object separately to sidestep around this
// but this wouldn't come up often anyway
nameGetter({ name: "kingdaro", age: 21 })
kingdaro
  • 11,528
  • 3
  • 34
  • 38
  • This completely bypasses the type system. You can now call it on any object, whether it has the right properties or not. You might as well not be using TypeScript in the first place. – Dave Cousineau Dec 16 '17 at 17:20
  • Not exactly @dave-cousineau. Yes, you can pass anything to `getId`. However the result will be typed accordingly. If you pass `getId({})` you will get `any`. However if you pass `getId({id: 4})` the result will be typed as a number. Or if if you pass `getId({id: undefined} as Partial<{id: number}>)` you will get `number | undefined` – chautelly Dec 16 '17 at 17:35
  • @chautelly `getId` can be called with anything, but `getProperty` can be called with anything, too. it does return the right type when used correctly though. – Dave Cousineau Dec 16 '17 at 17:39
  • Using the answer by @kingdaro, `getId(reocrd)` returns a `number` type for me – chautelly Dec 16 '17 at 17:43
1

If you split it into a two-step process, it can be minimally verbose and completely type safe at the same time:

interface recordType {
   id: number,
   label: string
}

const record = { id: 4, label: 'hello' };

const getPropertyBuilder = function <T>() {
   return <K extends keyof T>(key: K) => (o: T) => o[key];
};

const propertyBuilder = getPropertyBuilder<recordType>();
const getId = propertyBuilder('id'); // getId is (o: recordType) => number
const id = getId(record); // id is number

// or in one go
const label = getPropertyBuilder<recordType>()('label')(record); // label is string

Also works with Partial as mentioned:

const propertyBuilder = getPropertyBuilder<Partial<typeof record>>();
const getId = propertyBuilder('id');
const id = getId(record); // id is number
const id2 = getId({ id: 3 }); // also number
Dave Cousineau
  • 12,154
  • 8
  • 64
  • 80
1
type WithProp<T extends any, K extends string> = { [P in K]: T[P] }

function curriedGetProperty <P extends string>(prop: P) {
  return <T, O extends WithProp<T, typeof prop>>(o: O) => {
    return o[prop]
  }
}

Seems to type it safer.

const getId = curriedGetProperty('id')
getId({id: 'foo'}) // returns string
getId({label: 'hello'}) // fails
chautelly
  • 447
  • 3
  • 14
  • However, this does not work with `Partial`. Unless you make `WithProp` like `Partial` by adding a `?` before the `:` – chautelly Dec 16 '17 at 19:58
  • This allows `getId({})` which returns `any`. But does not allow `getId(43434)` or `getId(null)` – chautelly Dec 16 '17 at 20:04
  • 1
    I think this is about the same as mine, although yours does not require a second step so this may actually be better. You said it allows `getId({})` but for me it does not. And you said it does not allow `getId(null)` but for me it does. I don't think you can avoid being able to pass `null` or `undefined` without enabling strict null checking. Yours also doesn't bind to a particular type `T` until you use it, which makes the property getter usable with strong typing on any object which is also better. This is now the best answer. – Dave Cousineau Dec 16 '17 at 20:37
  • Yeah, I do have `strictNullChecks` enabled for my project – chautelly Dec 16 '17 at 22:08