This is now an issue at #17678, if you're here you should probably be there.
I'll probably put this up as a TS issue at some point, but I've been spamming them a bit over the last few days and I'm trying to play it cool.
This arrogantly assumes the implementation of #17636.
Given some type Foo
, construct a new type Bar
with a subset of the members of Foo
, where that subset is determined by a type predicate. Both the key and type of the member should be available to the predicate. Type predicates should have the full power of the type system.
This is analgous to Array.prototype.filter
, where the index and item value are available to the predicate.
Note that some of these may be achievable within the language today, albeit via round-about means. The aim is to have all of these acievable nicely.
// Example predicate, should be reusable
type IsWantedType<T> = ...;
// {foo: "world", bar(): void} -> {foo: "world"}
type NonFunctionMembers<T> = ...;
// {foo: "world", bar(): void} -> {bar(): void}
type FunctionMembers<T> = ...;
// {foo: string, fizz: object} -> {foo: string}
type PrimitivesOnly<T> = ...;
It seems there are two basic ways that filtering could be implemented within the context of mapped-types.
A mapped-type looks something like this:
type MappedType<T> = {[K in keyof T ]: T[K]};
// ^ ^
// A B
Where A
and B
strike me as the two obvious sites where a filtering syntax would fit. I'm calling these LHS and RHS filtering respectively.
At first glance, this seems the obvious place to put the filter. An intuitive syntax for this may look something like:
type FilteredType<T> = {[K in keyof T if ...]: T[K]};
Structurally this makes a lot of sense, and the for-in-if flow is nice. What to put in place of the ... is an issue, though.
Were a type declaration to be used here, some concept of meaningful return type would have to be introduced to type declarations.
Imagine the following example:
// Using type declaration overloads, this maps T to the types true or false
// Note that these are the TYPES true and false, NOT the values
type IsPrimitive<T extends object> = false;
type IsPrimitive<T> = true;
type PrimitivesOnly<T> = {[K in keyof T if IsPrimitive<T[K]>]: T[K]};
This actually reads really nicely. The downside to this is that we now have the compiler assigning meaning to the true
and false
types. This brings them uncomfortably close to values, and is a strange direction to take the language.
Another similar option would be to use truthiness and falsiness concepts here, perhaps any type other than never
could be true. This suffers from most of the same issues as just using true
and false
, though may lead to different (nicer?) predicate type declarations.
Constraint matching could be used for the predicate. An example predicate may be defined and used like so:
type IsObject<T extends object> = T;
type ObjectsOnly<T> = {[K in keyof T if IsObject<T[K]>]: T[K]};
The readability of this is pretty good.
Here the result of the predicate is irrelevant, the filtering is done according to whether or not T[K]
satisfies the constraints of the predicate. This seems like a nice approach until you realise that this manner of defining predicates is significantly less powerful and expressive. You can specify as many cases as you like where it does match, but there's currently no syntax to specify a special case that doesn't match.
// The lack of primitive type in the language means we have to list these allowed cases exhaustively
type IsPrimitive<T extends string | number | boolean | symbol> = T;
// Well now we're stuck, as there's no way to define a particular case that fails to match
type IsNotNumber<T> = ...;
One potential solution to this is subtraction-types.
// Can subtraction-types like this provide the full power of the other methods?
type IsNotNumber<T extends any - number> = T;
Another solution may be to introduce a new type that I'll call throw
for now. A type that resolves to throw
would be considered a compile-error in most contexts, and would function the same as a constraint-match failure in a mapped-type.
// Compile-error
type Foo = throw;
// This overloaded type would behave as if the second overload doesn't exist
type Foo<T extends number> = number;
type Foo<T> = throw;
// This would be a working IsPrimitive<T>
type IsPrimitive<T extends object> = throw;
type InPrimitive<T> = T;
This throw
approach is pretty weird, but compared to subtraction-types arguably leads to neater predicates.
Filtering in the RHS position initially makes a little less semantic/intuitive sense. One nice way to think about it may be that the type can be a normal type, or it can be nonexistant.
For this approach I'm proposing a new special type that I'm calling vanish
until I think of something better. vanish
describes a member that does not exist.
These two interfaces are exactly identical. OnlyHasFoo1
does not have a member bar
, it would not show up in intellisense or keyof
results or anywhere else. There is no spoonbar
.
interface OnlyHasFoo1 {
foo: number;
bar: vanish;
}
interface OnlyHasFoo2 {
foo: number;
}
This would allow filters to be written as follows:
// Using type declaration overloads
type FilterPrimitivesOnly<T extends object> = vanish;
type FilterPrimitivesOnly<T> = T;
type PrimitivesOnly<T> = {[K in keyof T]: FilterPrimitivesOnly<T[K]>};
This does not read as nicely as the LHS filter approach above, but in my opinion, this gels considerably better with the nature of the language as it currently exists. While vanish
is a fairly odd concept, it still describes the behaviour of the type, rather than being a pseudo-value as true
and false
become in the LHS approach above.
vanish
may also find other uses. Perhaps declaration merging with vanish
could remove members? Or some complex type combinations would find a use for vanish
? That remains to be explored.
The specifics of vanish
would need to be pinned down, particularly how it combines with other types. vanish | T
-> vanish
, vanish & T
-> T
is where I'm at with that now, though that's not based on much. Interactions between vanish
and never
and any
would have to be worked out.
This also poses the question of whether any non-existant member access should now have type vanish
, and what that may mean and what use it may have. Should type Foo = {}["non-existant"]
now be vanish
?
I think some version of the constraint-matching type predicates solution would probably be my pick, but all of these have their pros and cons, and none is the clear leader in my eyes. There may of course be other better options I haven't even considered.
Issue created at #17678. This pointless and brief gist is now deprecated in favour of that issue.