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

Allow union types to pattern match type constraints #17325

Closed
SimonMeskens opened this issue Jul 20, 2017 · 25 comments
Closed

Allow union types to pattern match type constraints #17325

SimonMeskens opened this issue Jul 20, 2017 · 25 comments

Comments

@SimonMeskens
Copy link

SimonMeskens commented Jul 20, 2017

Not sure if this is a duplicate, I've searched, but couldn't find anything. Basically, allow union types to narrow types to fit constraints. I ran into this issue today, not allowing me to properly type a variable that I could clearly write the type for.

Would also negate the need for #12424 I think.

Specific case with fallbacks:

type A<T extends string> = { a: T };

type B<T> = A<T> | { b: number }

let x: B<string> // Should infer to type '{ a: string } | { b: number }'
let y: B<number> // Should infer to type '{ b: number }'

In this sample, we know there's an option that works regardless of T, so we only include the option with T if the constraint is met.

More general version allows pattern matching:

type A<T extends string> = { a: T };
type B<T extends number> = { b: T };

type C<T extends string | number> = A<T> | B<T>

let x: C<string> // Should infer to type '{ a: string }'

If the type system were able to solve this case, we'd basically get a really simple version of doing conditionals, type matching at a type level, etc.

Edge cases:
I would not make it order dependent because union types are not ordered. If multiple parts of the union check out, they should all be included. This is not an attempt at writing function overloads in types, though it could potentially be used as such for certain cases.

@olegdunkan
Copy link

Its looks like meta-programming, to force compiler to choose (evaluate) the type.

@SimonMeskens
Copy link
Author

SimonMeskens commented Jul 31, 2017

@olegdunkan

Its looks like meta-programming, to force compiler to choose (evaluate) the type.

Isn't that already what it does, all the time? The TypeScript compiler is constantly narrowing types, I'm just asking for another narrowing rule, namely that it eliminates impossible types from unions.

@olegdunkan
Copy link

olegdunkan commented Jul 31, 2017

type A<T extends string> = { a: T };
type B<T extends number> = { b: T };
type C<T extends string | number> = { c: T };

type D<T extends string | number> = A<T> | B<T> | C<T>

let x: D<string | number> ; //what to infer? {a:T} | {b:T} or {c:T}

And we break constraint rules.

@SimonMeskens
Copy link
Author

What do you mean, what to infer? That example doesn't have a narrowing constraint, so no narrowing happens. Result is {a:T} | {b:T} | {c:T}

@SimonMeskens
Copy link
Author

type A<T extends string> = { a: T };
type B<T extends number> = { b: T };
type C<T extends string | number> = { c: T };

type D<T extends string | number> = A<T> | B<T> | C<T>

let x: D<string | number> ; // x: {a:T} | {b:T} | {c:T}
let y: D<string> ; // x: {a:T} | {c:T}
let z: D<number> ; // x: {b:T} | {c:T}

@olegdunkan
Copy link

To understand each other. Do you suggest this form of discriminated unions

type T1<T extends U1> = ...;
type T2<T extends U2> = ...;
...
type Tn<T extends Un> = ...;

type U<T extends U1 | U2 | ... | Un> = T1<T> | T2<T> | ... | Tn<T>;

or

type T1<T extends ...> = U1;
type T2<T extends ...> = U2;
...
type Tn<T extends ...> = Un;

type U<T extends ...> = T1<T> | T2<T> | ... | Tn<T>;

where U1 | U2 | ... | Un are discriminated union?

@SimonMeskens
Copy link
Author

Neither I think?

This first example wouldn't be allowed, because if U is a number, the result would be never.

type T1<T extends string> = Array<T>;
type U1<U extends string | number> = T1<U>; // error here, as it is now

This second example is allowed, because all possible values of U result in a valid type.

type T1<T extends string> = Array<T>;
type T2<T extends number> = Set<number>;
type U1<U extends string | number> = T1<U> | T2<U>; // no error

let x: U1<"foo"> // x: Array<"foo">
let y: U1<4> // x: Set<4>
let z: U1<"bar" | 2> // x: Array<"bar"> | Set<2>

The compiler doesn't "pick a type", all it does is narrow the type by eliminating cases from the discriminated union T1<U> | T2<U> once the generic type is known.

@olegdunkan
Copy link

olegdunkan commented Jul 31, 2017

))) I see, third case, discriminated union is T1<T> | T2<T> | ... | Tn<T>
But notion of the discriminated union supposes that resulting type of T1<T> | T2<T> | ... | Tn<T> will be some (only one) of them.
Conclude, for each Tn the compiler checks if type argument respects Tn's constraint then include Tn in the union.
But last question, how does the compiler understand that user wants to implement a discriminated union?
Because now it is not valid code:

type A<T extends string> = { a: T };
type B<T extends number> = { b: T };

type C<T extends string | number> = A<T> | B<T>; //error, C<T> constraint must be assignable to A<T> and B<T> constraints 

@SimonMeskens
Copy link
Author

SimonMeskens commented Jul 31, 2017

I think you're confused about what a discriminated union union type is. the "|" symbol in TypeScript denotes one basically. I'm not asking for a new feature, just some extra leniency on one end and a new type narrowing rule on the other.

@SimonMeskens SimonMeskens changed the title Allow discriminated unions to pattern match type constraints Allow union types to pattern match type constraints Jul 31, 2017
@SimonMeskens
Copy link
Author

I've replaced all instances of "discriminated union" with just "union type" to make it clearer. Not all union types are discriminated and I think that was causing some confusion. My mistake for not using the right words. Is it clearer now?

@olegdunkan
Copy link

Now, It is clear what you want. Thanks.

@gcnew
Copy link
Contributor

gcnew commented Aug 1, 2017

Generally, I'm in two minds about type level pattern matching. On the one hand, it will allow interesting use cases to be properly typed, on the other, it'd add significant complexity. It's not clear how to do type inference, either.

type A<T extends string> = { a: T };
type B<T> = A<T> | { b: number }

Accepting definitions such as the above B worries me, because it breaks the intuition that T is valid for each of the constituents. A more straightforward approach has been proposed by @isiahmeadows in #13257, however its very very complicated. A less ambitions proposal might be a better start.

PS: Guards should be really simple, otherwise proving that moderately complex guards are total is far from trivial or even impossible - e.g. GADTs meet their match: pattern-matching warnings that account for GADTs, guards, and laziness.

@dead-claudia
Copy link

dead-claudia commented Aug 2, 2017

Edit: Regarding my proposal in #13257.

@gcnew Yeah, and I should really re-work it enough to make the semantics a little clearer (it acts like it'll either match or error completely, when in reality, it should match or just fail, reusing that error if it's reasonably close).

@SamPruden
Copy link

I don't think this does cover all of the features of #12424. Currently I have a situation where I want to branch a base class to one type, and any children to another. While I have just thought of a way this version could work in that very specific situation, I don't think this provides a general solution for that.

Basically, I'd want to be able to match the first matching type, not all. Think about switch fall through and default.

Let's say we want to map numbers to strings, and leave everything else untouched.

type NumbersToStrings<T extends number> = string;
type Default<T> = T;
type Switched<T> = NumbersToStrings<T> | Default<T>;
// Oops, Switched<number> is string | number

We could make that particular example work by exhaustively constraining Default<T> to anything but number, but that wouldn't work in every case. I guess if we had #4183 that might be generally possible, but it would very quickly get large and messy.

I think overload style switching on the first match is more useful.

@KiaraGrouwstra
Copy link
Contributor

KiaraGrouwstra commented Aug 5, 2017

@gcnew:

Generally, I'm in two minds about type level pattern matching.

Though not exposed to utilize from the type level yet, isn't that what function overloading already does?

@SimonMeskens:

type A<T extends string> = { a: T };
type B<T> = A<T> | { b: number }
let y: B<number> // Should infer to type '{ b: number }'

You appear to suggest discarding any union options that turn out to error, is that correct?

Every non-union can be considered a unary union. Following that reasoning, what would prevent this proposal from having errors on such 'non-union' (= unary union) types to get converted to the empty union never? That would be unfortunate, as we could no longer see any of the errors we're getting.

So yeah, I share @gcnew's concerns on this proposal.

A combination of union iteration + 6606 (overloads to swallow errors) could potentially solve something like that, by ensuring it wouldn't come to actual errors. Both seem far off though. Edit: sorry, overloads only swallow "your type doesn't match this type" errors, doesn't protect from "your thing already errored".

As a simpler alternative though, with just type-level type-checks (also 6606) one might also be able to conditionally branch based on whether types would match the required inputs, e.g. type B<T> = If<Matches<T, string>, A<T>, never> | { b: number }.

@SimonMeskens
Copy link
Author

See, the issue I have with these more complex solutions is that they don't fit the feel of the language. I feel like we need a super simple feature that lets us construct these higher level concepts instead of one feature to rule them all that can do all of the things out of the box.

@SamPruden
Copy link

SamPruden commented Aug 6, 2017

Forgive me if this is already one of the proposals, I haven't read through every issue on this topic yet.

Thinking about trying to make this fit into the language as raised by @SimonMeskens, what about copying the existing syntax for function overloading, and creating type overloading?

type Foo<T extends number> = {aNumber: number};
type Foo<T extends boolean> = {aNumber: boolean};
type Foo<T extends any> = {aDefault: T};

type FooNumber = Foo<number>; // = {aNumber: number};
type FooString = Foo<string>; // = {aDefault: string};

So basically allow overloads for type declarations, and get the type from the first one that's valid. Just like functions.

@aluanhaddad
Copy link
Contributor

I think it would be better to pay the syntactic cost for an orthogonal notation than to introduce a second order syntax.

It definitely took me a while to get used to the syntax of Mapped Types but it can be used anywhere.

@SimonMeskens
Copy link
Author

I'm not sure. Haskell has an orthogonal notation and I hear that a lot of people wish for a simpler ordered list. I actually like that propo

@SimonMeskens
Copy link
Author

Sorry, phone is freaking out. I actually like that proposal a lot

@SamPruden
Copy link

SamPruden commented Aug 6, 2017

@SimonMeskens Do you mean the idea I just threw out? I could write it up as a separate issue if there's interest in at least discussing it.

@SimonMeskens
Copy link
Author

I'm very interested yeah. It might even make aspects of polymorphism easier to type

@SamPruden
Copy link

@SimonMeskens Okay, that's cool. I'm focused on some work right now, but I'll see if I can get an issue up for that within the hour. No promises.

@SamPruden
Copy link

I'm late, but I've posted @17636 for that so we don't keep derailing this issue.

@SimonMeskens
Copy link
Author

I'm actually going to close this one, because after looking at all the edge cases, this one plain doesn't work or becomes so complex as to be unusable. Feel free to ask me to open it again if you can fix this proposal and want it over the superior #6606 and #17636 proposals that cover the same ground.

@microsoft microsoft locked and limited conversation to collaborators Jun 14, 2018
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests

7 participants