Shaun Luttin
Shaun Luttin

Reputation: 141512

Extract data property names from a constrained generic parameter

We have a generic type that successfully extracts data properties from a type that it receives. It's in the playground and it works like this.

type DataPropertyNames<T> = {
  [K in keyof T]: T[K] extends Function ? never : K;
}[keyof T];

type FooBarBaz = {
  foo: string;
  bar: Function;
  baz: number;
};

const f1 = () => {
  type DataProps = DataPropertyNames<FooBarBaz>;

  const x: DataProps = 'foo'; // good, no error, because foo is a string

  const y: DataProps = 'bar'; // good, expected error, because bar is a Function.

  return {
    x,
    y
  };
};

The example above shows DataPropertyNames working when we pass it a concrete type such as FooBarBaz. As intended, it discards props that are Functions, and it keeps props that are not functions.

Our question is about why the following does not work as expected. We expected that DataProps would accept the 'foo' string, because T extends FooBarBaz.

const f2 = <T extends FooBarBaz>() => {
  type DataProps = DataPropertyNames<T>;

  const x: DataProps = 'foo'; // bad, unexpected error

  const y: DataProps = 'bar';

  return {
    x,
    y
  };
};

Why can TypeScript not figure out that T has at least the same data property names as the type it extends? How, if at all, can we convince TypeScript of that?

Upvotes: 1

Views: 265

Answers (2)

MacD
MacD

Reputation: 426

The answer from Mu-Tsan Tsai is correct, I just wanted to expand on it for some clarity that simple generic types do work. I've always been curios about this scenario and I think providing an experiment where we can see when the types become too complex is useful. You can use the example below, (through the playground link) with future versions of typescript to see if things are improving (my experiment was run in TS 4.0.2).

In the code below when the generic T is used directly with an extends constraint then we have access to properties of the class used in the generic constraint. This is good and expected, no surprise. When you have a very simple generic type (basicGenType) that takes T it also works, in my case I used the keyof operator and the generic type was able to extract the keys from T. I was actually a little surprised at this as I thought any generic type would fail when using T as it's generic argument. For a slightly more complex basic type it fails (basicGenType2, basicGenType3). It seems once I started inferring a new type (K in this case) and used it to compose the resulting type that was the breaking point. An interesting observation is that intellisense auto-completes the key field for both basicGenType2, basicGenType3.

Once I tested extends and infer it also failed, this was not a surprise and it matches your experience.

type basic = {simple:number}
type basicGenType<T> = keyof T
type basicGenType2<T> = {[K in keyof T]: K}
type basicGenType3<T> = {[K in keyof T]: T[K]}
type complexGenType1<T> = T extends basic ? number : never
type complexGenType2<T> = T extends {simple:infer R} ? R : never

const g = <T extends basic>(a:T):void => {

  //directly on T typescript can use the properties of basic
  a.simple = 5; //GOOD --typescript is good with this
  a.simple = "5" //GOOD -- typescript complains 
  a.other = 5;  //GOOD -- typescript complains

  //for a simple generic type typescript still works
  //the resulting type should only allow the value 'simple'
  type basicType = basicGenType<T>
  let b:basicType = 'simple'; //GOOD -- typescript is ok with this
  b = 'other'; //GOOD -- typescript complains

  //for a slightly more complicated simple type we fail
  // the resulting type should only allow the value {simple:'simple'}
  type basicType2 = basicGenType2<T>
  let b2:basicType2 = {simple:'simple'}; //BAD -- typescript fails even though it should be ok with this

   //for a slightly more complicated simple type we fail
  // the resulting type should only allow the value {simple:number}
  type basicType3 = basicGenType3<T>
  let b3:basicType3 = {simple:3}; //BAD -- typescript fails even though it should be ok with this

  //we've introduced extends here for complexity
  //complexType shoud resolve to number here
  type complexType1 = complexGenType1<T>;
  let c:complexType1 = 5;//BAD -- typescript fails even though it should be ok with this

  //we've introduced extends and infer here for even more complexity
  //complexType2 shoud resolve to number here
  type complexType2 = complexGenType2<T>;
  let d:complexType2 = 5;//BAD -- typescript fails even though it should be ok with this
}

Playground Link

Upvotes: 2

Mu-Tsun Tsai
Mu-Tsun Tsai

Reputation: 2534

Currently TypeScript is not so sophisticated that it can resolve a generic parameter through a complex definition and simplify it by the constraint of the parameter; even if it could, in your case the result should be something that looks like unknown | 'foo' | 'baz', where unknown is inevitable since we really don't know what's in T, and this will simply be simplified again into just unknown, which is not really helpful.

But for your particular case, one way to make it work is using the following instead:

type DataProps = DataPropertyNames<T> | DataPropertyNames<FooBarBaz>;

Unless your type constraint is yet another generic parameter, this should satisfy your needs.

Upvotes: 2

Related Questions