Skip to content

Instantly share code, notes, and snippets.

@SamPruden
Last active August 8, 2017 05:56
Show Gist options
  • Save SamPruden/eb60ba29f0091fdd87926b3eeeaee392 to your computer and use it in GitHub Desktop.
Save SamPruden/eb60ba29f0091fdd87926b3eeeaee392 to your computer and use it in GitHub Desktop.
Personal notes on type filtering in TS, not quite issue worthy yet

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.

Aim of mapped-type filtering

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.

Examples that should be achievable

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> = ...;

How to do it?

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.

LHS filtering

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.

Pseudo-value type predicates

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 type predicates

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.

RHS filtering

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?

In summary / thoughts

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.

@sompylasar
Copy link

The Truthy and Falsy types can potentially be expressed as a union type of the literal types, although certain type logic is required to put the non-empty string, the non-zero number, and the non-null object into the Truthy type declaration as they do not have respective constant representations like true for the boolean type.

@aluanhaddad
Copy link

aluanhaddad commented Aug 8, 2017

My feeling is that filtering on the right hand side is problematic since the visual distinction between applied types and type predicates is lost and it becomes awkward to project the right hand side.

The following seems fairly clear

type ObjectsOnly<T> = {[K in keyof T if IsObject<T[K]>]: Partial<T[K]>};

but the following reads strangely to me

type ObjectsOnly<T> = {[K in keyof T]: Partial<IsObject<T[K]>>};

as does

type ObjectsOnly<T> = {[K in keyof T]: IsObject<Partial<T[K]>>};

since Partial is a well known transformation on T, we can probably figure it out, but it could be anything really, including another type predicate.

@SamPruden
Copy link
Author

The true and false literal types are already available, yes. But having the type system operate on them according to their values (that is, treating the type true as true in an expression) would be very different.

The RHS filtering is definitely harder to read. It's in there because it requires (subjectively) less drastic modification to the language, but it wouldn't be my pick if LHS filtering were a viable option.

I wasn't particularly expecting this to receive attention, and the discussion capabilities here are limited, so I think I may post this as an issue tomorrow. Or now. But then there's the potential that I could get caught up in discussions and not do any actual work again and that would be irresponsible. I'll do it now.

@SamPruden
Copy link
Author

SamPruden commented Aug 8, 2017

Issue created at #17678. This pointless and brief gist is now deprecated in favour of that issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment