TypeScript icon indicating copy to clipboard operation
TypeScript copied to clipboard

Feature Request: "extends oneof" generic constraint; allows for narrowing type parameters

Open Nathan-Fenner opened this issue 5 years ago • 74 comments

Search Terms

  • generic bounds
  • narrow generics
  • extends oneof

Suggestion

Add a new kind of generic type bound, similar to T extends C but of the form T extends oneof(A, B, C).

(Please bikeshed the semantics, not the syntax. I know this version is not great to write, but it is backwards compatible.)

Similar to T extends C, when the type parameter is determined (either explicitly or through inference), the compiler would check that the constraint holds. T extends oneof(A, B, C) means that at least one of T extends A, T extends B, T extends C holds. So, for example, in a function

function smallest<T extends oneof(string, number)>(x: T[]): T {
    if (x.length == 0) {
        throw new Error('empty');
    }
    return x.slice(0).sort()[0];
}

Just like today, these would be legal:

smallest<number>([1, 2, 3);        // legal
smallest<string>(["a", "b", "c"]); // legal

smallest([1, 2, 3]);               // legal
smallest(["a", "b", "c"]);         // legal

But (unlike using extends) the following would be illegal:

smallest<string | number>(["a", "b", "c"]); // illegal
// string|number does not extend string
// string|number does not extend number
// Therefore, string|number is not "in" string|number, so the call fails (at compile time).

// Similarly, these are illegal:
smallest<string | number>([1, 2, 3]);       // illegal
smallest([1, "a", 3]);                      // illegal

Use Cases / Examples

What this would open up is the ability to narrow generic parameters by putting type guards on values inside functions:

function smallestString(xs: string[]): string {
    ... // e.g. a natural-sort smallest string function
}
function smallestNumber(x: number[]): number {
    ... // e.g. a sort that compares numbers correctly instead of lexicographically
}

function smallest<T extends oneof(string, number)>(x: T[]): T {
    if (x.length == 0) {
        throw new Error('empty');
    }
    const first = x[0]; // first has type "T"
    if (typeof first == "string") {
        // it is either the case that T extends string or that T extends number.
        // typeof (anything extending number) is not "string", so we know at this point that
        // T extends string only.
        return smallestString(x); // legal
    }
    // at this point, we know that if T extended string, it would have exited the first if.
    // therefore, we can safely call
    return smallestNumber(x);
}

This can't be safely done using extends, since looking at one item (even if there's only one item) can't tell you anything about T; only about that object's dynamic type.

Unresolved: Syntax

The actual syntax isn't really important to me; I just would like to be able to get narrowing of generic types in a principled way.

(EDIT:) Note: despite the initial appearance, oneof(...) is not a type operator. The abstract syntax parse would be more like T extends_oneof(A, B, C); the oneof and the extends are not separate.

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. new expression-level syntax)

(any solution will reserve new syntax, so it's not a breaking change, and it only affects flow / type narrowing so no runtime component is needed)

Nathan-Fenner avatar Oct 11 '18 20:10 Nathan-Fenner

The use case sounds like a duplicate of #24085, though it seems you've thought through more of the consequences. Let's close this in favor of #24085.

mattmccutchen avatar Oct 11 '18 20:10 mattmccutchen

Sounds like you want an "exclusive-or" type operator - similar to #14094.

DanielRosenwasser avatar Oct 11 '18 23:10 DanielRosenwasser

I don't think that's it since string and number are already exclusive so a string xor number type wouldn't be distinguishable from string | number. It's more like they want two overloads:

function smallest<T extends string>(x: T[]): T;
function smallest<T extends number>(x: T[]): T;

But not string | number because smallest([1, "2"]) is likely to be an error.

ghost avatar Oct 11 '18 23:10 ghost

It is close to a combination of #24085 and #25879 but in a relatively simple way that ensures soundness is preserved.

It only affects generic instantiation and narrowing inside generic functions. No new types or ways of types are being created; an xor operator doesn't do anything to achieve this.

Nathan-Fenner avatar Oct 12 '18 04:10 Nathan-Fenner

T extends oneof(A, B, C) means that at least one of T extends A, T extends B, T extends C holds.

~~That is what a union constraint does.~~ Do you not mean exactly one of?

jack-williams avatar Oct 12 '18 08:10 jack-williams

That is what a union constraint does.

No because if T = A | B | C then none of T extends A, T extends B, T extends C holds. (Were you reading extends backwards?)

mattmccutchen avatar Oct 12 '18 12:10 mattmccutchen

True! No I wasn't, but I had parsed it in my head like (T extends A) | (T extends B) | (T extends C).

jack-williams avatar Oct 12 '18 14:10 jack-williams

What about a XOR type operator? This would be useful in other scenarios as well. As | is a valid bitwise operator in JS for OR, it would fit using ^ as XOR operator in types.

function smallest<T extends string ^ number>(x: T[]): T {
    if (x.length == 0) {
        throw new Error('empty');
    }
    return x.slice(0).sort()[0];
}

michaeljota avatar Apr 29 '19 20:04 michaeljota

@michaeljota See previous comments for why that doesn't work. There is no type today that could be put inside the extends to get the desired behavior. Therefore, new type operations cannot solve this problem, since they just allow you (potentially conveniently) write new types. The type number ^ string is exactly the same as string | number, since there is no overlap between string and number. It does nothing to solve this problem.

This has to be solved by a different kind of constraint rather than a different type in the extends clause.

Nathan-Fenner avatar Apr 29 '19 20:04 Nathan-Fenner

You want one of a giving list of types, right? I really don't know much about types, sorry. Still, OR operator can be satisfied with n types of the interception, and a XOR operator should only allow one of the types in the interception.

I understand that if you create a interception, string | number, you can use only the functions of both types, and you should narrow down to one of those to use those functions. Also, if you declare a generic type as T extends string | number, then T would accept string, number or string | number. Then a XOR operator used in a generic type as T extends string ^ number should only accept strictly one of string or number, excluding the interception itself string | number.

That's not what you would like this to do?

michaeljota avatar Apr 29 '19 21:04 michaeljota

The problem with that approach is that the ^ is incoherent anywhere outside of an "extends" clause, so it becomes (pointless) syntactic sugar.

For example, what should

var x: string ^ number = 4;

do? Are you allowed to reassign it to "hello"? Or, in more-complicated examples, like:

var x: Array<string ^ number> = [2, 5, "hello"];

why should/shouldn't this be allowed?

There's no coherent way that this can be done. The only way to give it meaning is to only allow it in extends clauses, where it has the meaning of

type Foo<T extends A ^ B> = {}
// means the same thing as
type Foo<T extends_oneof(A, B)> = {}

It still represents a new type of constraint, not a type operator (because it doesn't produce coherent types).

Nathan-Fenner avatar Apr 29 '19 21:04 Nathan-Fenner

For example, what should

var x: string ^ number = 4;

do? Are you allowed to reassign it to "hello"?

I think it should. In this case, I guess this would behave the same as string | number.

Or, in more-complicated examples, like:

var x: Array<string ^ number> = [2, 5, "hello"];

why should/shouldn't this be allowed?

Here it would be useful, as you either want an array of strings, or an array of numbers, but not both. I think, this would be something like:

var x: Array<string ^ number> = [2, 5, 'hello']
                                ~~~~~~~~~~~~~~~ // Error: Argument of type '(string | number)[]' is not assignable to parameter of type 'string[] | number[]'.

// So (string ^ number)[], would be string[] | number[] 

Playground link

michaeljota avatar Apr 29 '19 21:04 michaeljota

This would make #30769 less of a bitter pill to swallow...

jcalz avatar Jun 06 '19 18:06 jcalz

And would solve #13995, right?

jcalz avatar Jun 08 '19 00:06 jcalz

@jcalz It "solves" it in the sense that (with other compiler work) the following code would compile like you'd expect:

declare function takeA(val: 'A'): void;
export function bounceAndTakeIfA<AB extends_oneof('A', 'B')>(value: AB): AB {
    if (value === 'A') {
        // we now know statically that AB extends 'A'
        takeA(value);
        return value;
    }
    else {
        // we now know statically that AB does not extend 'A', so it must extend 'B'
        return value;
    }
}

It should be noted that besides the actual implementation of extends_oneof as a new form of constraint, the way that constraints are calculated/propagated for generic types needs to change. Currently, TS never adds/changes/refines generic type variable constraints (because as the conversation above shows, you can never really be sure about the value of the generic type, only of particular values of that type).

This feature makes it possible to soundly refine the constraints for type parameters, but actually doing that refinement would be a bit more work.

Nathan-Fenner avatar Jun 08 '19 02:06 Nathan-Fenner

Here is anohter example:

I have a numeric library. Now I wan't to generalize it to get number or bigint (but not both).

function add<Num extends bigint | number>(a: Num, b: Num) {
    var d1: Num = a + b;
    return a + b
}
var d2 = add(1n, 2n);

But I don't have a way to do this.

https://www.typescriptlang.org/play/index.html?target=7&ssl=13&ssc=1&pln=1&pc=1#code/FAMwrgdgxgLglgewgAgM5gLYAoCGAuZAIzgHM4IYAaIg4sigSmQG9hl3kA3HAJ2SgCMeOuRjIAvMhzIA1EQDcbDjwCmMMDxTS5hYAF9g3PlABMEtJiwCI1ExAaLQkWIi0ATNwB4AcpmQqADxgVCDdUIlJRZAAfZAhMQhUeAD5cAl8MakJ0zCZWDi5eZDchZAzzbQUldlV1TSlZIn1DIrczSRwPKxtkOwdgYCA

emilioplatzer avatar Jul 13 '20 14:07 emilioplatzer

I suggest the following syntax: T extends T1 || T2. Readed T exteds type T1 or extends type T2. When I see T extends T1|T2 y read T extends type type T1|T2 i.e. a variable that can holds sometimes T1 and sometimes T2.

function smallest<T extends string||number>(x: T[]): T {
    if (x.length == 0) {
        throw new Error('empty');
    }
    return x.slice(0).sort()[0];
}

emilioplatzer avatar Jul 13 '20 15:07 emilioplatzer

Extended example from #40851, which I hope could be solved with oneof:

function useObjectWithSingleNamedKey<Key extends string>(
  keyName: Key,
  value: string,
  fn: (obj: {[key in Key]: string}) => void)
{
  fn({ [keyName]: value }) // <-- Fails with `extends string`, but hopefully wouldn't with `oneof`
  fn({ [keyName]: value } as { [key in Key]: string }) // <-- Unwanted workaround
}

// Seemingly works with `extends string`:
useObjectWithSingleNamedKey('foobar', 'test', obj => {
  console.log(obj.foobar)  // <-- Works, and should
  console.log(obj.notHere) // <-- Does not work, and should not
});

// But this "works" too, and shows why `extends string` is actually not enough:
useObjectWithSingleNamedKey<'foobar' | 'notHere'>('foobar', 'test', obj => {
  console.log(obj.foobar)  // <-- Works, and should
  console.log(obj.notHere) // <-- Works, but should not
});

Playground Link

Svish avatar Sep 30 '20 15:09 Svish

Another variant is "exclusive or" ^:

function smallest<T extends string ^ number>(x: T[]): T { ... }
function sum<T extends string ^ number ^ bigint>(a: T, b: T) {  return a + b; }

TypeScript already have disjunction via | and conjunctions via & so ^ seems to me look logical here. As generic's constrain this could behave exactly the same as oneof but here for example:

function sum(a: string ^ number ^ bigint, b: string ^ number ^ bigint) {}

will behave the same as:

function sum(a: string | number | bigint, b: string | number | bigint) {}

except arrays ofc. WDYT?

MaxGraey avatar Oct 16 '20 14:10 MaxGraey

@MaxGraey have you read previous comments in this thread?


What follows is my opinion. I'm saying this here so I don't have to say "I think" and "in my opinion" in every sentence:

The problem with any suggestion that takes T extends A | B and does something to A | B (e.g., T extends A ^ B, T extends A || B, T extends A 🤡 B) is that it messes with what TypeScript types are supposed to be: the (possibly infinite) collection of JavaScript values that are assignable to the type.

For example, the type boolean can be represented by the set { x where typeof x === "boolean" }, a.k.a., {true, false}, while the type string can be represented by the set { x where typeof x === "string" }. The type unknown is represented by the the collection { x where true } of all JavaScript values , and the type never is represented by the empty set { x where false } of no JavaScript values. Unions and intersections of types correspond to unions and intersections of their corresponding sets.

As soon as you say string ^ boolean to mean something other than string | boolean in any context, you're saying "you can't think of string ^ boolean as a type anymore", at least not in the sense of a set of acceptable values. Since there are no JavaScript values which are both string and boolean, the type string ^ boolean as a set of values is exactly the same as string | boolean. In order to mean something different, you'd have to say that types are actually collections of all their possible subtypes, only some of which can be cashed out to sets of values. Which is a huge amount of extra cognitive load for what you get out of it.

This could be mitigated by a rule that ^ can only appear in a type if it is in the extends clause of a generic constraint, similar to how infer can only modify a type if it is in the extends clause of a conditional type. But this still puts more burden on TS users than I'd want, since they would undoubtedly try to use A ^ B in disallowed contexts.

If we are trying to represent "(T extends A) or (T extends B)", it is better to think of this as a different sort of constraint and not a different sort of type. That is, change the extends part of T extends A | B , not the A | B part. This leads us to T extends_oneof(A, B) or T in (A, B) or T 🐔 (A, B), etc.

jcalz avatar Oct 16 '20 15:10 jcalz

@MaxGraey have you read previous comments in this thread?

Oh no, I missed this! Sorry.

Regarding rest part I agree that context dependent type via "exclusive or" is not perfect solution, but using T extends_oneof A, B, T in A, B, T is A, B via comma separation is bad idea due to it produce ambiguous:

function foo<T in A, B>(a: T, b: T) {} // T is A or B, one parametric type `T`
function foo<T in A, B>(a: T, b: B) {} // T is A, two parametric types `T` and `B`

Perhaps this could be:

function foo<T in A | B>(a: T, b: B) {}

But now we it produce union type which we are just trying to avoid)

MaxGraey avatar Oct 16 '20 15:10 MaxGraey

(Edited above to change A,B to (A,B)) yeah, you need some delimiter... sure T in (A, B) or maybe T of [A, B], or the original proposal extends_oneof(A, B)

jcalz avatar Oct 16 '20 15:10 jcalz

T extends A 🤡 B [...] T 🐔 (A, B)

LOL. Now if we just had keys on the keyboard for those...

mattmccutchen avatar Oct 16 '20 15:10 mattmccutchen

Another variant just make this works as expected 😂

function sum<T extends number | string, U = T extends number ? number : string>(a: T, b: U) {
    return a + b;
}

MaxGraey avatar Oct 16 '20 16:10 MaxGraey

Another variant just make this works as expected joy

function sum<T extends number | string, U = T extends number ? number : string>(a: T, b: U) {
    return a + b;
}

Nope:

let x: number | string = 5;
let y: string = "hello";
let z = sum(x, y);

mattmccutchen avatar Oct 16 '20 16:10 mattmccutchen

function sum<T extends number | string, U = T extends number ? number : string>(
  a: T, 
  b: U
): U extends number ? number : string {
    return a + b;
}

let x: number | string = 5;
let y: string = "hello";
let z = sum(x, y);  // will infer as string

But this incorrect anyway

MaxGraey avatar Oct 16 '20 16:10 MaxGraey

But this incorrect anyway

That's the point. It should produce an error, but it doesn't.

MartinJohns avatar Oct 16 '20 16:10 MartinJohns

Here is a basic User class:

class User< U > {

    private _user: Partial< U > = {};

    public set< K extends keyof U >(

        key: K,
        val: U[ K ],

    ) : void {

        this._user[ key ] = val;

    }
}

And a basic Employee interface:

interface Employee {

    name: string;
    city: string;
    role: number;

}

Now, consider the following implementation:

const admin = new User< Employee >();

admin.set( "name", 10 );

The above script yields the following error which is expected.

Argument of type 'number' is not assignable to parameter of type 'string'.

Now take a look at the following implementation:

const admin = new User< Employee >();

admin.set< "name" | "role" >( "name", 10 );

The above script does not yield any error and it is also expected, as long as 10 is assignable to name or role.

To make it more clear, let's take a look at the following script:

const admin = new User< Employee >();

admin.set< "name" | "role" >( "name", true );

The above script yields the following error which is also expected.

Argument of type 'boolean' is not assignable to parameter of type 'string | number'.


Now, what I want to achieve is, 10 should not be allowed to be stored as a value of name at any mean.

For this, I need to make some changes to my code something similar to the followings:

public set< K extends keyof U >(

    key: K,
    val: U[ valueof key ],

) : void {

    this._user[ key ] = val;

}

But as long as there is nothing like valueof key, this cannot be achieved dynamically.

For testing, if you replace U[ K ] by U[ "name" ], you will see 10 is no longer allowed anymore at any form.

However, is there any way to achieve my goal?

Many programmers solved similar problems by applying T[ keyof T ] which I think is not applicable to this case.

Thanks in advance.

Playground

AnmSaiful avatar Oct 25 '20 20:10 AnmSaiful

@AnmSaiful your problem is not really relevant to this issue (there is a sense in which it is similar - but we do not need to know that a value is a singleton to correctly handle your case) - it has to be with the (lack of) contravariance of assignment, and TypeScript's (deliberately) unsound handling of this case. See my comment for the correct type in your code.

Nathan-Fenner avatar Oct 25 '20 20:10 Nathan-Fenner

@Nathan-Fenner I also think so, but after seeing @MartinJohns's comment on #41233 I thought perhaps implementation of this feature will also implement the feature I requested.

AnmSaiful avatar Oct 26 '20 09:10 AnmSaiful