Issue with generic properties when type mapping

count and value will always make the compiler unhappy. To fix it you might try something like this:

{
  value,
  count: 1,
  transform: (data: Partial<Thing<T>>) => {
   ...
  }
}

Since Partial utility type is being used, you will be ok in the case transform method is not present.

Stackblitz


This is an interesting problem. Typescript can't generally do much with regard to generic type parameters in conditional types. It just defers any evaluation of extends if it finds that the evaluation involves a type parameter.

An exception applies if we can get typescript to use a special kind of type relation, namely, an equality relation (not an extends relation). An equality relation is simple to understand for the compiler, so there is no need to defer the conditional type evaluation. Generic constraints are one of the few places in the compiler where type equality is used. Let's look at an example:

function m<T, K>() {
  type Bad = T extends T ? "YES" : "NO" // unresolvable in ts, still T extends T ? "YES" : "NO"

  // Generic type constrains are compared using type equality, so this can be resolved inside the function 
  type Good = (<U extends T>() => U) extends (<U extends T>() => U) ? "YES" : "NO" // "YES"

  // If the types are not equal it is still un-resolvable, as K may still be the same as T
  type Meh = (<U extends T>()=> U) extends (<U extends K>()=> U) ? "YES": "NO" 
}

Playground Link

We can take advantage of this behavior to identify specific types. Now, this will be an exact type match, not an extends match, and exact type matches are not always suitable. However, since Action is just a function signature, exact type matches might work well enough.

Lets see if we can extract types that match a simpler function signature such as (v: T) => void:

interface Model<T> {
  value: T,
  other: string
  action: (v: T) => void
}

type Identical<T, TTest, TTrue, TFalse> =
  ((<U extends T>(o: U) => void) extends (<U extends TTest>(o: U) => void) ? TTrue : TFalse);

function m<T>() {
  type M = Model<T>
  type KeysOfIdenticalType = {
    [K in keyof M]: Identical<M[K], (v: T) => void, never, K>
  }
  // Resolved to
  // type KeysOfIdenticalType = {
  //     value: Identical<T, (v: T) => void, never, "value">;
  //     other: "other";
  //     action: never;
  // }

}

Playground Link

The above type KeysOfIdenticalType is close to what we need for filtering. For other, the property name is preserved. For the action, the property name is erased. There is just one pesky issue around value. Since value is of type T, is is not trivially resolvable that T, and (v: T) => void are not identical (and in fact they may not be).

We can still determine that value is identical to T: for properties of type T, intersect this check for (v: T) => void with never. Any intersection with never is trivially resolvable to never. We can then add back properties of type T using another identity check:

interface Model<T> {
  value: T,
  other: string
  action: (v: T) => void
}

type Identical<T, TTest, TTrue, TFalse> =
  ((<U extends T>(o: U) => void) extends (<U extends TTest>(o: U) => void) ? TTrue : TFalse);

function m<T>() {
  type M = Model<T>
  type KeysOfIdenticalType = {
    [K in keyof M]:
      (Identical<M[K], (v: T) => void, never, K> & Identical<M[K], T, never, K>) // Identical<M[K], T, never, K> will be never is the type is T and this whole line will evaluate to never
      | Identical<M[K], T, K, never> // add back any properties of type T
  }
  // Resolved to
  // type KeysOfIdenticalType = {
  //     value: "value";
  //     other: "other";
  //     action: never;
  // }

}

Playground Link

The final solution looks something like this:

// Filters out an object, removing any key/values that are of Action<any> type
type State<Model extends object, G = unknown> = Pick<Model, {
    [P in keyof Model]:
      (Identical<Model[P], Action<Model, G>, never, P> & Identical<Model[P], G, never, P>)
    | Identical<Model[P], G, P, never>
  }[keyof Model]>;

// My utility function.
type Action<Model extends object, G = unknown> = (data: State<Model, G>) => State<Model, G>;


type Identical<T, TTest, TTrue, TFalse> =
  ((<U extends T>(o: U) => void) extends (<U extends TTest>(o: U) => void) ? TTrue : TFalse);

interface MyModel<T> {
  value: T; // 👈 a generic property
  str: string;
  doSomething: Action<MyModel<T>, T>;
  method() : void
}


function modelFactory<T>(value: T): MyModel<T> {
  return {
    value,
    str: "",
    method() {

    },
    doSomething: data => {
      data.value; // ok
      data.str //ok
      data.method() // ok 
      data.doSomething; // Does not exist 👍
      return data;
    }
  };
}

/// Still works for simple types
interface MyModelSimple {
  value: string; 
  str: string;
  doSomething: Action<MyModelSimple>;
}


function modelFactory2(value: string): MyModelSimple {
  return {
    value,
    str: "",
    doSomething: data => {
      data.value; // Ok
      data.str
      data.doSomething; // Does not exist 👍
      return data;
    }
  };
}

Playground Link

NOTES: The limitation here is that this only works with one type parameter (although it can possibly be adapted to more). Also, the API is a bit confusing for any consumers, so this might not be the best solution. There may be issues that I have not identified yet. If you find any, let me know 😊


Generally I read that twice and don't fully understand what you want to achieve. From my understanding you want to omit transform from the type which is given to exactly transform. Achieving that is simple, we need to use Omit:

interface Thing<T> {
  value: T; 
  count: number;
  transform: (data: Omit<Thing<T>, 'transform'>) => void; // here the argument type is Thing without transform
}

// 👇 the factory function accepting the generic
function makeThing<T>(value: T): Thing<T> {
  return {
    value,
    count: 1,
      transform: data => {
        data.count; // exist
        data.value; // exist
    },
  };
}

Not sure if this is what you wanted because of quite a complexity you have given in the additional utility types. Hope it helps.


It would be great if I could express that T is not of type Action. Sort of an inverse of extends

Exactly like you said, the problem is we don't have negative constraint yet. I also hope they can land such feature soon. While waiting, I propose a workaround like this:

type KeysOfNonType<A extends object, B> = {
  [K in keyof A]-?: A[K] extends B ? never : K
}[keyof A];

// CHANGE: use `Pick` instead of `Omit` here.
type State<Model extends object> = Pick<Model, KeysOfNonType<Model, Action<any>>>;

type Action<Model extends object> = (data: State<Model>) => State<Model>;

interface MyModel<T> {
  value: T;
  doSomething: Action<MyModel<T>>;
}

function modelFactory<T>(value: T): MyModel<T> {
  return {
    value,
    doSomething: data => {
      data.value; // Now it does exist 😉
      data.doSomething; // Does not exist 👍
      return data;
    }
  } as MyModel<any>; // <-- Magic!
                     // since `T` has yet to be known
                     // it literally can be anything
}