#37790Medium

Chainable Picker

Type a pick(obj).name.age() chain where every step narrows what is left. An accumulator type parameter remembers the picked keys so none can be picked twice.

pick(obj) hands you a value that is two things at once: an object you can keep reaching into, and a function you can call to finish.

Each property you touch is one of the object's keys, and touching it takes that key out of circulation for the rest of the chain. Calling the result with () returns an object made of exactly the keys you visited, in whatever order you visited them. The type has to track that growing set as you go, which is the part the compiler does not do for you.

type Person = {
  name: string
  id: string
  age: number
  email: string
}
 
const obj = {} as Person
 
const result1 = pick(obj).name.age()
// result1: { name: string, age: number }
 
const result2 = pick(obj).email.id.name()
// result2: { email: string, id: string, name: string }
 
const invalid = pick(obj).name.name() // error, name is already taken
const invalid2 = pick(obj).invalid() // error, no such key

Challenge Instructions: Chainable Picker

Medium

Implement a pick function that returns a chainable object allowing you to select properties from an object by chaining property access. When you call the chain with (), it returns an object containing only the picked properties.

The chainable picker should:

  • Allow chaining property names to select multiple properties
  • Prevent picking the same property twice
  • Only allow picking properties that exist on the object
  • Return the picked object when called with ()

For example:

type Person = {
name: string
id: string
age: number
email: string
}
 
const obj = {} as Person
 
// Pick multiple properties by chaining
const result1 = pick(obj).name.age()
// result1: { name: string, age: number }
 
const result2 = pick(obj).email.id.name()
// result2: { email: string, id: string, name: string }
 
// You cannot pick the same property twice
const invalid = pick(obj).name.name() // Type error!
 
// You can only pick existing properties
const invalid2 = pick(obj).invalid() // Type error!

View on GitHub: https://tsch.js.org/37790

Change the following code to make the test cases pass (no type check errors).

ChallengeSolution
/* _____________ Your Code Here _____________ */

type Picker<T> = {}

declare function pick<T>(obj: T): Picker<T>

/* _____________ Test Cases _____________ */
import type { Equal, Expect } from '../helpers'

type Person = {
  name: string
  id: string
  age: number
  email: string
}

type User = {
  username: string
  password: string
  role: 'admin' | 'user'
  createdAt: Date
  lastLogin: Date
}

const person = {} as Person
const user = {} as User

type cases = [
  // Basic single property pick
  Expect<Equal<Pick<Person, 'name'>, ReturnType<ReturnType<typeof pick<Person>>['name']>>>,

  //

Pro Challenge

Unlock 170+ medium, hard, and extreme challenges to master advanced TypeScript.

Monthly subscription. Cancel anytime.

Detailed Explanation

The solution in full:

type Picker<T, Picked extends keyof T = never> = {
  [K in Exclude<keyof T, Picked>]: Picker<T, Picked | K>
} & (() => Pick<T, Picked>)
 
declare function pick<T extends Record<string, unknown>>(obj: T): Picker<T>

Three lines for the type, one for the function. The interesting part is the second type parameter, which the caller never writes.

One value, two shapes

pick(obj).name has to be readable as an object (so .age works after it) and callable (so () works after it). A single object type cannot be both, but an intersection can:

[object Object]

Anything of type Chain accepts .age and accepts a call. That intersection is the skeleton of the whole solution: the mapped type on the left is what you can still reach for, and the call signature on the right is what you get if you stop here.

An accumulator for what you already took

Picked is a set of keys carried through the recursion, and it starts empty. The empty union is never, which is why the default is never rather than something like '':

[object Object]

Unioning anything with never gives that thing back, so the first step adds a key to an empty set without any special case for the first step.

Two utilities do the bookkeeping. Exclude<keyof T, Picked> lists the keys still available, and Pick<T, Picked> builds the result out of the keys already taken. Evaluate the starting point by hand:

// Picker<Person> is Picker<Person, never>
// Exclude<keyof Person, never>  ->  'name' | 'id' | 'age' | 'email'
// Pick<Person, never>           ->  {}

So pick(obj) offers all four keys and, if called right away, returns an empty object. Reach for .name and the mapped type hands back Picker<Person, never | 'name'>:

// Picker<Person, 'name'>
//   = { id: ...; age: ...; email: ... } & (() => Pick<Person, 'name'>)

name is gone from the property side, and the call signature has grown a key. One more step, .age, and Picked is 'name' | 'age', which is exactly the type the first chained test expects.

Why the recursion stops

Every step moves one key from the Exclude side to the Picked side, and keyof T is finite. After four steps on Person, Exclude<keyof Person, 'name' | 'id' | 'age' | 'email'> is never, a mapped type over never is {}, and the value that is left is only a function. There is nothing more to reach for, so the chain cannot grow further.

Recursion inside a type alias is fine here because the reference to Picker sits in a property position of a mapped type. TypeScript defers that until someone actually indexes into it, so writing Picker<Person> does not expand all twenty-four possible chains at once.

Rejecting things that are not plain objects

The constraint on pick carries more weight than it looks:

[object Object]

T extends object would be too loose, since arrays and functions are objects too. Record<string, unknown> demands a string index signature, and TypeScript grants an implicit one only to object types written as type aliases or literals. Arrays, functions and the primitive wrappers do not get one, so pick([]), pick(() => {}), pick(1) and pick('1') are all rejected, while Person and User pass.

Edge cases the tests cover

This challenge is originally from here.

Share this challenge

Related Challenges

Learn the Concepts

Become a TypeScript Pro

Track your progress through 100+ hands-on challenges. Free, sign in with GitHub.

Or start solving right away: explore all TypeScript challenges