Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Mapped type filtering - thoughts and discussion #17678

Closed
SamPruden opened this issue Aug 8, 2017 · 8 comments
Closed

Mapped type filtering - thoughts and discussion #17678

SamPruden opened this issue Aug 8, 2017 · 8 comments
Labels
Duplicate An existing issue was already created Suggestion An idea for TypeScript

Comments

@SamPruden
Copy link

SamPruden commented Aug 8, 2017

This was briefly a gist, but now it's here with bonus typo (one was actually spelling, but I can pretend) corrections and vanish renamed to delete.

This arrogantly assumes the implementation of #17636 - declaration type overloading.

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 analogous 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 achievable 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 delete until I think of something better. delete 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 delete 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.

delete may also find other uses. Perhaps declaration merging with delete could remove members? Or some complex type combinations would find a use for delete? That remains to be explored.

The specifics of delete would need to be pinned down, particularly how it combines with other types. vanish | T -> delete, vanish & T -> T is where I'm at with that now, though that's not based on much. Interactions between delete and never and any would have to be worked out.

This also poses the question of whether any non-existent member access should now have type delete, and what that may mean and what use it may have. Should type Foo = {}["non-existent"] now be delete?

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.

@aluanhaddad
Copy link
Contributor

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.

I like the use of the delete keyword.

@SamPruden
Copy link
Author

@aluanhaddad I think, with experience and discipline, the RHS method could probably be acceptably readable, but I absolutely agree the LHS way is far better in that respect. RHS would rely very heavily on strong naming conventions. A LHS approach is definitely my preference.

@aluanhaddad
Copy link
Contributor

Regarding the use of true and false, that is actually good because it provides a basis for composition.
So if I have

type IsPrimitive<T> = true;
type IsPrimitive<T extends object> = false;

and

type HasToFixed<T> = false;
type HasToFixed<T extends {toFixed(): string}> = true;

Then they could be composed as

type IsPrimitiveWithToFixed<T> = IsPrimitive<T> && HasToFixed<T>;

I'm not sure if that is a good direction...

@SamPruden
Copy link
Author

I'm not sure if that is a good direction...

Yeah, nor am I. That composition example is interesting, but using types as values like that is really strange, and potentially a bit of a weird slippery slope. It feels like it's compromising something about the integrity of the language in a way.

It's also worth noting that this becomes easy with TDOs:

type And<A extends true, B extends true> = true;
type And<A extends boolean, B extends boolean> = false;

type IsPrimitiveWithToFixed<T> = And<IsPrimitive<T>, HasToFixed<T>>;

@rzvc
Copy link

rzvc commented Aug 9, 2017

I think there are two separate issues here.

  1. Conditional types.
type SuperType<boolean> = string;
type SuperType<string>  = boolean;

Which may have their uses, but I didn't really feel a need for them.

  1. An extension for keyof, to make it conditional (everywhere it can be used).
type Partial<T, PT> =
{
	[P in keyof T if PT]?: T[P];
	[P in keyof T if not PT]: T[P];
}

function trimBush<T, K extends keyof T if string>(obj: T, to_trim: K[]) : void;

type StringKeys<T> = keyof T if string;

Personally, I don't care about conditional types, but I'd very much like to see conditional keyof implemented, because it would add so much.

About deleting properties in mappings, I propose not doing it at all. Instead, type subtraction should be introduced (and with it, negative types, because any - T = not T, so.. -T). Then deleting something can be done like this:

type Without<T, TN> =
{
	[P in keyof T if -TN]: T[P];
}

// Or, combined:
type WithAndWithout<T, TWith, TWithout> =
{
	[P in keyof T if TWith - TWithout]: T[P];
}

Obviously the subtraction part doesn't have to be implemented right away, but it's gotta come at some point, hopefully soon.

The problem with deleting, that subtraction would solve, is that I don't think you should delete a property that matches T, if the property's type is T | X, instead the property's type should become X and only get removed if no type remains.

interface A
{
	a: boolean;
	b: string | number;
	c: string;
}

// See Without above.
type B = Without<A, string>;

// So B would be:
interface B
{
	a: boolean;
	b: number;
}

Edit: Small change in the logic of negative types.

@SamPruden
Copy link
Author

I think there are two separate issues here.

  1. Conditional types.
type SuperType<boolean> = string;
type SuperType<string>  = boolean;

Which may have their uses, but I didn't really feel a need for them.

I don't entirely follow this, sorry. Do you mean the declaration type overloading? Yes, that is a separate issue, that's what #17636 is. See that thread for the fact that it does actually have lots of uses. This issue is basically a spin-off from #17636 investigating specifically how mapped type filtering (conditional keyof) might work in a world where we had #17636.

@rzvc
Copy link

rzvc commented Aug 10, 2017

I'm sure it has valid uses, I guess I could have used them in a couple of places, but conditional keyof is much more valuable and I think it should be higher priority.

Anyway, my point is that they're separate issues and they don't collide in any way when it comes to mapped type filtering, because that's a job for conditional keyof, while RHS filtering should never be a thing.

Problems with RHS filtering:

  1. Hides what's going on - you can never know what's happening by simply looking at the type.
  2. It would allow the filtering to happen in a different file.

It's basically a feature designed specifically to help you shoot yourself in the foot.

I'd also like to bring into the discussion the syntax for conditional keyof. There were other issues before these two that discussed conditional mapping and another syntax that was suggested was the one in #13214:

P in keyof T where T[P] extends number | string

While I like the if type one too, maybe it can be used as a shorthand with the full syntax having the where clause in it, because it allows querying other types, based on the current key.

I'd go as far as suggesting the use of AND and OR operators in that query so you can do:

P in keyof T where T[P] extends number | string and O[P] extends object

@mhegazy mhegazy added Suggestion An idea for TypeScript Duplicate An existing issue was already created labels Aug 22, 2017
@mhegazy
Copy link
Contributor

mhegazy commented Aug 22, 2017

This is another proposal for #12424 , based on #17636.

@mhegazy mhegazy closed this as completed Apr 12, 2018
@microsoft microsoft locked and limited conversation to collaborators Jul 25, 2018
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Duplicate An existing issue was already created Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

4 participants