TypeScript icon indicating copy to clipboard operation
TypeScript copied to clipboard

Allow conditional check on generic parameters

Open lsby opened this issue 2 years ago • 6 comments

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:

  • [x] This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • [x] This wouldn't change the runtime behavior of existing JavaScript code
  • [x] This could be implemented without emitting different JS based on the types of the expressions
  • [x] This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
  • [x] 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>(a:A, b:B) where [EQ<A,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>(a:A) where [IsString<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>(a:A) where [IsPhoneNumber<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>(a:A, b:B) where [Include<list, A>, Include<list, B>, NOT<EQ<A,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.

lsby avatar Nov 02 '22 11:11 lsby

Duplicate #42388

RyanCavanaugh avatar Nov 02 '22 16:11 RyanCavanaugh

@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 avatar Nov 02 '22 17:11 fatcerberus

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.

fatcerberus avatar Nov 02 '22 17:11 fatcerberus

@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.

lsby avatar Nov 02 '22 18:11 lsby

@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.

fatcerberus avatar Nov 02 '22 20:11 fatcerberus

@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.

lsby avatar Nov 02 '22 23:11 lsby