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 conditional check on generic parameters #51385

Open
5 tasks done
lsby opened this issue Nov 2, 2022 · 6 comments
Open
5 tasks done

Allow conditional check on generic parameters #51385

lsby opened this issue Nov 2, 2022 · 6 comments
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript

Comments

@lsby
Copy link

lsby commented Nov 2, 2022

Suggestion

🔍 Search Terms

List of keywords you searched for before creating this issue. Write them down here so that others can find this suggestion more easily and help provide feedback.

  • generics
  • constraints
  • conditional
  • check

✅ Viability Checklist

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.

⭐ Suggestion

Allow conditional constraints on generic parameters.

📃 Motivating Example

  1. You can constrain generics by type calculation, like this:
type EQ<A, B> = A extends B ? B extends A ? true : false : false

function f<A, B> where [EQ<A,B>] (a:A, b:B) { ... }

f(1,2) // ok
f(1,'a') // err

This describes a function whose two arguments must be of the same type.

The where field provides an list, each item of this list is a type calculation, and the function can only be called if each item is true.

  1. For simplicity, this constraint is only useful when calling the function, and does not affect the judgment of this generic type inside the function.
type IsString<A> = A extends String ? true : false

function f<A> where [IsString<A>] (a:A) {
    // Inside the function, don't know that the type of A is a string.
}

f('a') // ok
f(1) // err
  1. This check is post-processing, that is, when calling the function, the type of the generic type is first deduced through parameters, etc., and finally it is checked whether the constraints in where are satisfied. So this won't affect the existing generic parameter logic.

💻 Use Cases

I think this provides two benefits:

  1. Can describe generic conditions more freely, not just subtype constraints.

For example, there is a function that expects to enter a phone number:

type IsPhoneNumber<A extends string> = ...

function f<A extends string> where [IsPhoneNumber<A>] (a:A) { ... }
  1. Allows describing the relationship between multiple generics, and is more expressive.

For example, a function takes two arguments, both of which are items in a given list, and the arguments cannot be duplicates:

type EQ<A, B> = A extends B ? B extends A ? true : false : false
type NOT<A>=A extends false? true: false
type Include<list, item> = list extends [] ? false : list extends [infer x, ...infer xs] ? item extends x ? true : Include<xs, item> : false

type list = ['a', 'b', 'c']

function f<A, B> where [Include<list, A>, Include<list, B>, NOT<EQ<A,B>>] (a:A, b:B) { ... }

My workaround now is to do a type check on the return value of the function and return never if it doesn't match the condition:

type EQ<A, B> = A extends B? B extends A? true: false: false

function f<A, B>(a:A, b:B): EQ<A,B> extends true? string: never { ... }

But this has many problems.

First, the return value is inferred as a conditional type and I need to convert it manually:

type EQ<A, B> = A extends B? B extends A? true: false: false

function f<A, B>(a:A, b:B): EQ<A,B> extends true? string: never { return 'a' as any }

Second, when the wrong parameter is entered, the type of the return value is never, which is different from reporting an error.
Although most of the time, when I try to use a value of type never, I get an error.

@RyanCavanaugh
Copy link
Member

Duplicate #42388

@RyanCavanaugh RyanCavanaugh added the Duplicate An existing issue was already created label Nov 2, 2022
@fatcerberus
Copy link

@RyanCavanaugh I don't think #42388 is the same. The only thing it has in common is use of the where keyword; otherwise, 42388 just looks like an alternative syntax for specifying upper bounds for individual type parameters, while this one is asking to use the where clause to implement arbitrary constraints via conditional types.

@fatcerberus
Copy link

fatcerberus commented Nov 2, 2022

For what it's worth, Turing complete constraints sound awesome on the surface, but in practice I think they will end up having limited appeal. This works great in first-order situations where the compiler can directly evaluate the conditional type in the where clause, but because unbound type parameters are largely treated nominally, it will tend to fall over in higher-order situations:

function foo<T, U> where Equals<T, U>(x: T, y: U) {
    // ...
}

function bar<T, U> where Equals<T, U>(x: T, u: U) {
    foo(x, y);  // error, distinct type parameters never compare equal
}

And that's assuming the conditional type isn't just completely deferred, in which case the only safe thing for the compiler to do is to reject the call.

@lsby
Copy link
Author

lsby commented Nov 2, 2022

@RyanCavanaugh I've seen this question and I don't think it's quite the same as my suggestion.
#42388 is more like putting generic constraints in a unified place, while what I expected was describing conditional constraints between generics.

@fatcerberus You're right.
Yes, I also don't expect it to work when the generic type cannot be determined.
But in fact, if a generic type doesn't have the extends constraint, then we won't be able to do anything with the value of that type, which is rare.
So generics are usually used with the extends constraint, in which case the call condition can be determined from the extends constraint.

@fatcerberus
Copy link

fatcerberus commented Nov 2, 2022

@lsby Adding an extends constraint doesn’t invalidate my concern. The issue is when one generic function calls other ones, in which case TS can’t verify the where clause for the inner call(s). If TS defaults to allowing the call in this case just because the extends constraints are met, then that’s not really useful because then the constraint effectively becomes T extends U OR where P<T>. I’d expect it to be an AND there instead.

@lsby
Copy link
Author

lsby commented Nov 2, 2022

@fatcerberus Oh! You are right!

In the case of generic functions calling other generic functions, denying the call is really the only safe way.
I'll give an easier-to-understand example:

type IsLT5<A> = ...
type IsLT10<A> = ...

function f1<A extends number> where [IsLT5<A>](x: A) {
    // ...
}

function f2<A extends number> where [IsLT10<A>](x: A) {
    f1(x)
}

Suppose we have two types of calculations, IsLT5 and IsLT10, means less than 5 and less than 10.
For example, IsLT5<1> gets true and IsLT5<6> gets false.

For f1(x), it is unsafe to consider only extends, such as the case where x is 6.

This is really unsatisfactory, but difficult to achieve by narrowing down the generic type with the where field.

A possible method is to analyze the calling process of the function,
and when it is found that x in f2 calls f1,
copy the where condition of f1 on this parameter to f2,
That is, TS first converts this code internally to:

type IsLT5<A> = ...
type IsLT10<A> = ...

function f1<A extends number> where [IsLT5<A>](x: A) {
    // ...
}

function f2<A extends number> where [IsLT10<A>, IsLT5<A>](x: A) {
    f1(x)
}

But this seems to complicate the implementation.

I think the easiest way is to not allow the value of the generic type to call a function with a where field,
but allow the programmer to ignore the where check by casting, for example:

type IsLT5<A> = ...
type IsLT10<A> = ...

function f1<A extends number> where [IsLT10<A>](x: A) {
    // ...
}

function f2<A extends number> where [IsLT5<A>](x: A) {
    f1(x as any)
}

In this case, f1(x) is safe, because numbers less than 5 must also be less than 10.

But it is difficult for TS to know this.
We can specify that the where check always allows values of type any.
This allows the programmer to specify x as type any to ignore the where check.
Of course, this requires the programmer to know what himself is doing.

This may not be elegant, but I think it's useful,
such type conversions do not pollute the outside of the function,
the programmer just has to make sure not to make mistakes here.

@RyanCavanaugh RyanCavanaugh removed the Duplicate An existing issue was already created label Nov 4, 2022
@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature labels Nov 18, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

3 participants