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
}