#20901•Hard

Key Type From Props

Turn a union of component variants into a lookup table keyed by its discriminant, and constrain the generic so a key the union does not have is a compile error.

A form config built from a component registry usually ends up as one wide union. Every field is an AInput, an ASelect or an ARangePicker, and each variant carries its own value type. KeyTypeFromProps<T, K, V> folds that union back into a lookup table: keys taken from the discriminant K, values taken from V on whichever variant owns that key.

Two things make it awkward. The union is not at the top level of the input, it sits inside an intersection with some shared extras. And three of the four tests are @ts-expect-error lines, so the generic parameters have to be constrained tightly enough that a wrong key is rejected instead of quietly producing an empty object.

type obj = {
  [x: string]: (
    | { component: 'AInput'; value: string }
    | { component: 'ASelect'; value: string }
    | { component: 'ARangePicker'; value: [number, number] }
  ) & { label?: string }
}
 
KeyTypeFromProps<obj, 'component', 'value'>
// { AInput: string; ASelect: string; ARangePicker: [number, number] }

Challenge Instructions: Key Type From Props

Hard

Implement type that extracts key and type both from object props. More details please refer to test cases.

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

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

ChallengeSolution
/* _____________ Your Code Here _____________ */

type KeyTypeFromProps<T, K, V> = any

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

type obj = {
  [x: string]: (
    | {
        component: 'AInput'
        value: string
      }
    | {
        component: 'ASelect'
        value: string
      }
    | {
        component: 'ARangePicker'
        value: [number, number]
      }
  ) & {
    label?: string
  }
}

type res = {
  AInput: string
  ASelect: string
  ARangePicker: [number, number]
}

type cases = [
  Expect<Equal<KeyTypeFromProps<obj, 'co

Pro Challenge

Get access to all 200+ challenges, including every medium, hard, and extreme one.

One-time payment, lifetime access. 30-day money-back guarantee.

Detailed Explanation

The solution in full:

type Props<T> = T[keyof T]
 
type KeyTypeFromProps<
  T extends Record<string, object>,
  K extends keyof Props<T>,
  V extends keyof Props<T>,
> = {
  [P in Props<T>[K] & PropertyKey]: Extract<Props<T>, Record<K, P>>[V]
}

Every property value as one union

Props<T> is T[keyof T], the union of all property value types of T. Indexing a type with a union of keys gives you the union of the matching property types, so for an object with many differently typed fields you would get all of them back at once. Here obj has a single string index signature, so there is only one value type to collect:

// keyof obj  ->  string | number
// Props<obj> ->  ({ component: 'AInput', value: string } | ... ) & { label?: string }

An intersection that behaves like a union

That result still looks like a single intersection. It is not. When one member of an intersection is a union, TypeScript rewrites the whole thing into a union of intersections, pushing the union back to the top:

// (A | B | C) & L
//   becomes
// (A & L) | (B & L) | (C & L)

This normalisation is what the rest of the solution rides on. Conditional types only distribute over a union when the union is at the top level of the checked type, so if the compiler kept the intersection as written, every step below would see one opaque member instead of three.

From the union to the key set

Props<T>[K] reads the discriminant off each member and unions the answers:

[object Object]

That union of string literals is the key set of the result, so it goes straight into the mapped type as [P in Props<T>[K] & PropertyKey]. The & PropertyKey is a note to the compiler rather than a change in meaning. Inside a generic signature TypeScript cannot prove that Props<T>[K] is usable as a key, and intersecting with string | number | symbol is the cheapest way to say so. On the concrete instantiation it removes nothing.

Picking the member a key belongs to

For each key P, the value we want lives on exactly one variant: the one whose K property is P. Extract finds it. Extract<X, Y> is defined as X extends Y ? X : never, and because its first argument is a bare type parameter, the conditional is distributive: TypeScript runs the test once per union member and unions whatever survives.

Record<K, P> builds the pattern to test against, an object type with the single property K of type P. Then the surviving member gets indexed with V:

// P = 'ARangePicker'
// Record<'component', 'ARangePicker'>  ->  { component: 'ARangePicker' }
//
// A & L extends { component: 'ARangePicker' }  ->  never
// B & L extends { component: 'ARangePicker' }  ->  never
// C & L extends { component: 'ARangePicker' }  ->  C & L
//
// (C & L)['value']  ->  [number, number]

Run that for all three keys and the mapped type is complete: AInput: string, ASelect: string and ARangePicker: [number, number].

The constraints carry the error cases

T extends Record<string, object> says that T indexes strings to object types. { x: 1 } fails it because 1 is not an object, and [] fails it because a tuple type has no string index signature at all.

K extends keyof Props<T> and V extends keyof Props<T> restrict the two key parameters to properties the union actually has. keyof over a union returns only the keys shared by every member, which here is 'component' | 'value' | 'label'. Anything else, including the empty string literal '', is out.

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 200+ hands-on challenges. Free, sign in with GitHub.

Or start solving right away: explore all TypeScript challenges