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

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

Open
4 tasks done
Nathan-Fenner opened this issue Oct 11, 2018 · 87 comments
Open
4 tasks done
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript

Comments

@Nathan-Fenner
Copy link
Contributor

Nathan-Fenner commented Oct 11, 2018

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:

  • 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. 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)

@mattmccutchen
Copy link
Contributor

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.

@DanielRosenwasser
Copy link
Member

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

@DanielRosenwasser DanielRosenwasser added Suggestion An idea for TypeScript In Discussion Not yet reached consensus labels Oct 11, 2018
@ghost
Copy link

ghost commented Oct 11, 2018

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.

@Nathan-Fenner
Copy link
Contributor Author

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.

@jack-williams
Copy link
Collaborator

jack-williams commented Oct 12, 2018

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?

@mattmccutchen
Copy link
Contributor

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?)

@jack-williams
Copy link
Collaborator

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

@michaeljota
Copy link

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];
}

@Nathan-Fenner
Copy link
Contributor Author

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

@michaeljota
Copy link

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?

@Nathan-Fenner
Copy link
Contributor Author

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

@michaeljota
Copy link

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

@jcalz
Copy link
Contributor

jcalz commented Jun 6, 2019

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

@jcalz
Copy link
Contributor

jcalz commented Jun 8, 2019

And would solve #13995, right?

@Nathan-Fenner
Copy link
Contributor Author

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

@angryzor
Copy link
Contributor

angryzor commented May 29, 2023

A first class existential type

I think I have been conflating my oneof operator and existentially quantified types over the constituents. I had already suspected something wasn't quite right because every oneof always had to have their children wrapped in unions.

Unions really are universal quantifiers over the set of their constituents, and oneofs are a separate but similar thing: existential quantifiers over the set of their constituents. With my current oneof operator it is possible to group oneofs inside unions, but not the other way around:

type Foo = oneof (A | B) | oneof (C | D)
type Bar = oneof ((A | B) | (C | D)) // won't work, will collapse

I based everything around this oneof operator because that's what worked for me for the mapping problem, but I don't think this is ideal or the most flexible. The type you actually want here for Bar but can't express is (A | B) ^ (C | D) (borrowing the caret operator from above), and it's a different type than A ^ B ^ C ^ D:

A ^ B ^ C ^ D extends (A | B) ^ (C | D)
// => true, all of the left side constituents can find one on the right that they are a subtype of

(A | B) ^ (C | D) extends A ^ B ^ C ^ D
// => false, none of these left side constituents are subtypes of the right side constituents

So basically @michaeljota was right with their suggestion all along, it's just that the semantics of this ^ operator are not that "a value is not allowed to match 2 of the constituents", it just signifies existential quantification over its constituents, where unions signify universal quantification over their constituents.

What are oneof and allof then? Well, they are still very useful, and we need them for the mapping problem. They just convert between ^ (existential) types and | (union) types. Simple.

So now I have a new task on my list, after I finish implementing inference:

  • Making a new first class type ExistentialType that takes over most of the oneof implementation.
  • Change oneof into just a type operator with little extra logic.

Currently the union/intersection types in the compiler look like this:

Type
└ UnionOrIntersectionType
  ├ IntersectionType
  └ UnionType

I plan to change this to this:

Type
└ SetOperationType
  ├ IntersectionType
  └ QuantificationType
    ├ UnionType
    └ ExistentialType

This way I can reuse all the collapsing and other "treat constituents as a set" logic from unions and implementation should be rather smooth.

I am unsure about the renaming part since it will cause a huge amount of changed LoC (even though it's just a find all and replace for me) and might make it harder to maybe have an eventual PR merged, but if I don't then the naming will be confusing. We'll have to see. I prefer having a clean code structure I think.

@angryzor
Copy link
Contributor

angryzor commented Jun 14, 2023

What to do here

A bit longer than a week ago I presented a (back then) strange and confusing observation I ran into in a channel in the TS community discord. One of the other users helpfully responded to my observation and since then I have been a bit stuck on this project. Not because I realized I was completely on the wrong path, but because their comment pointed out a situation where a choice needs to be made, and I'm stuck on this choice, stuck on which one is the "best" or "correct" one. Since then it's been 2 weeks of very little progress, so I thought it might be better to try writing down my thoughts. Maybe someone else could chime in, or maybe writing everything down could bring me to the correct conclusion through some form of rubber ducking. So here we go.

Getting up to speed

The core problem we're trying to solve with the oneof implementation is this: considering the following code, what is the type that should be inferred for T?

string[] | number[] extends (infer T)[]

Currently the typescript compiler will infer string | number. This is correct, but only because we are using the extends constraint. It is not the most specific type that can be inferred. In other words, were we to use the as-of-yet-nonexistent constraint type equals then this inference would not be correct:

string[] | number[] equals (infer T)[]

The reason why becomes clear when you look at this code:

type A = string[] | number[] extends (string | number)[] ? true : false
//   ^? true
type B = (string | number)[] extends string[] | number[] ? true : false
//   ^? false

There is a subtle difference between string[] | number[] and (string | number)[] here. string[] | number[] is either an array of strings or an array of numbers, (string | number)[] is an array where every item can be either a string or a number. The uneven subtyping relation between the two here is correct. If I have an array that only contains strings or an array that only contains numbers, then I can also advertise it as an array that can hold both strings and numbers. (At least, I could do that if it were immutable and that is where this becomes a bit unsound for mutable arrays but let's ignore that for the moment as it is out of scope of the oneof solution.) We can't do the inverse. Presenting an array that can hold both as one that can only hold either one or the other isn't safe.

The same difference exists when you use a simpler type composition like an object property, but it's even more subtle:

type A = { foo: string } | { foo: number } extends { foo: string | number } ? true : false
//   ^? true
type B = { foo: string | number } extends { foo: string } | { foo: number } ? true : false
//   ^? false

{ foo: string } | { foo: number } here is either an object that you can only get a string out of by accessing foo, or an object that you can only get a number out of by accessing foo. Which of the 2 you are holding you don't know, and you'll first have to narrow the type somehow. { foo: string | number } is the type of an object that you can access a property foo on, and that property can hold either a string or a number.

So when we are trying to solve the oneof problem, we are trying to define a type that directly embodies this peculiar relationship in the distributed versions of these types.

There is a contravariant complement of this pattern, with intersections:

type A = string[] & number[] extends (string & number)[] ? true : false
//   ^? false
type B = (string & number)[] extends string[] & number[] ? true : false
//   ^? true

type C = { foo: string } & { foo: number } extends { foo: string & number } ? true : false
//   ^? false
type D = { foo: string & number } extends { foo: string } & { foo: number } ? true : false
//   ^? true

In this case the difference is even more subtle because in practice I can't immediately think of a situation where this difference is significant except in situations where a contravariant interaction places us back into the union version, but it's there. { foo: string } & { foo: number } for example is the type of an object that you can access foo and get a string back and an object that you can access foo on and get a number back. { foo: string & number } is the type of an object that you can access foo on and get a value back that is both a string and a number. Note the subtyping relation is flipped in this case.

It may not immediately be apparent why all of this is related to the oneof type as we see it in function parameters. However it is directly related. The way we currently define functions where we would want to use the oneof type (removing the type parameters for a moment as they are not significant) is like this:

type MyFn1 = (x: A | B) => void

But what we actually want to define is this type:

interface MyFn2 {
    (x: A): void
    (x: B): void
}

// or:
type MyFn2 = ((x: A) => void) & ((x: B) => void)

Note that MyFn1 is actually a subtype of MyFn2:

type A = ((x: A | B) => void) extends ((x: A) => void) & ((x: B) => void) ? true : false
//   ^? true

And note how this pattern looks extremely similar to the patterns above. That is because it is the exact same pattern. x is just in contravariant position, so we're looking for the union version of this oneof type while dealing with an intersection, but the core question is exactly the same: in MyFn2, what would be the type of x? The answer is the oneof type we are trying to define.

What is the issue that has me blocked?

(TBC...)

@laughinghan
Copy link

@Nathan-Fenner Function overloads seem to perfectly solve the example given, and for generic type aliases which don't have overloads, with a little elbow grease you can use conditional types:

// spurious tuple required to workaround distributivity of conditional types
type OneofStrNum<T extends [string | number]> = T[0] extends string ? string : T[0] extends number ? number : never
type MonomorphicArray<T extends string | number> = OneofStrNum<[T]>[]

type M1 = MonomorphicArray<string>
  // ^? type M1 = string[]
type M2 = MonomorphicArray<number>
  // ^? type M2 = number[]
type M3 = MonomorphicArray<string | number>
  // ^? type M3 = never[]

Sure it sucks but the main use case is functions anyway right? Can you motivate some non-function use cases so important that they deserve special syntax sugar for what can already be done with conditional types?

Otherwise can we close this?

@craigphicks
Copy link

craigphicks commented Dec 8, 2023

@angryzor

This port concern TypeScript behavior and are not at all a criticism of you or your ideas.

1

As you said, the typescript compiler will infer string | number for this type declaration case:

type A = string[] | number[] extends (infer T)[] ? T[] : never;
//  ?^  (string | number)[]

But if we write it as a type function,

type Id<In> =In extends (infer Out)[] ? Out[] : never;
type X1 = Id<string[]|number[]>;
//  ?^ string[] | number[]

it does not lose precision. For the distribution over the union functionality to work, the function form is required.

2

Another issue: there is some strange TypeScript behavior with the & calculations:

type SN1 = string[] & number[];
//     ^?  string[] & number[]
type T1 = SN1[0];
//   ?^ never

type A = string[] & number[] extends (string & number)[] ? true : false
//   ^? false
type A1 = never[] extends (string & number)[] ? true : false
//   ^? true

type B = (string & number)[] extends string[] & number[] ? true : false
//   ^? true
type B1 = (string & number)[] extends never[] ? true : false
//   ^? true

TypeScript seems to delay the calculation of & at times - as it does here.
Personally I think it would be better to immediately see

type SN1 = string[] & number[];
//     ^?  never[] 
  • A and A1 should be the same, right?
  • B and B1 should be the same, right?
  • Also A1 and B1 together imply that never[] and (string & number)[] both extend each other, which I believe should mean they are equal. However they are not equal.
  • never[] extends (string & number)[] should be true (A1), according to set theory.
  • (string & number)[] extends never[] should be false (B1), according to set theory.

Conclusion

TypeScript handling of types at sets is not really mathematically precise.
At least, there are a few corner cases that I think TypeScript does not properly handle (as of V5.3.3).

@emilioplatzer
Copy link

I think that this cannot be closed yet. It is unsolved.

@laughinghan, your given example fixes the problem of using a function that allows T1 or T2 but not T1|T2 union.

In my opinion that is not the problem (because it has a solution). The problem is implementing that function. Inside that function you cannot have a generic type T that can be T1 or T2 but not T1|T2. That is specialy bad for numeric types.

See my example in my previous comment: #27808 (comment)

@laughinghan
Copy link

@emilioplatzer Thank you, that's a much better example. Can we update the main issue description then? Both to mention the better example, and to mention that function overloads are an (incomplete) workaround. The fact that the best workaround requires annoying casts is a pretty good argument for this feature:

function add(a: number, b: number): number
function add(a: bigint, b: bigint): bigint
function add<Num extends bigint | number>(a: Num, b: Num): bigint | number {
    return (a as number) + (b as number);
}

More generally TypeScript doesn't really track correlations between types much at all (with distributive conditional types being a very limited exception); this does seem like potentially a small, practical way to specify type correlations in some useful cases.

@craigphicks
Copy link

craigphicks commented Dec 11, 2023

@laughinghan @emilioplatzer @Nathan-Fenner

There are really 3 separate items that are related:

  1. Specifying the call parameter and return type correlations.
  2. Using those correlations for flow analysis inside the function implementation and to validate the return statements therein.
  3. Using those correlations to validate calls to the function.

Item 3 is already working, item 2 is not.

Notice that @emilioplatzer post says

Inside that function you cannot have a generic type T that can be T1 or T2 but not T1|T2.

That is talking about item 3.

The OP #27808 title implies that a new form for specifying correlations (extends oneof) is the main feature - that feature belongs to item 1. However, the OP long content also talks about item 3.

  • It is possible to fix item 3 without adding a new feature for item 1.
  • It is possible to add a new oneof feature for item 1 without fixing item 3.

@nikelborm
Copy link

ts playground

Isn't it just a syntax sugar for writing more function overloads? You can achieve the same functionality with a few type helpers:

(Thanks to @jcalz for UnionToIntersection magic on stackoverflow)

Helpers

Definition

type UnionToIntersection<U> =
  (U extends any ? (_: U) => void : never) extends ((_: infer I) => void) ? I : never

// Here you define how your function overload will look
type Render<OneOfPossibleOptions> = <const T extends OneOfPossibleOptions>(x: T[]) => T

type ConvertTupleOfPossibleOptionsToOverloadsUnion<
  TupleOfPossibleOptions
> = TupleOfPossibleOptions extends [infer OneOfPossibleOptions, ...infer RestOfPossibleOptions]
    ?
      | Render<OneOfPossibleOptions>
      | ConvertTupleOfPossibleOptionsToOverloadsUnion<RestOfPossibleOptions>
    : never;

type ConvertTupleOfPossibleOptionsToOverloadsIntersection<
  TupleOfPossibleOptions
> = UnionToIntersection<
  ConvertTupleOfPossibleOptionsToOverloadsUnion<
    TupleOfPossibleOptions
  >
>;

Tests and usage

import { Equals, assert } from 'tsafe';

// since ts doesn't have a way to reliably compare equality of types
// (including comparing overloads like `{
//   (_: ('A' | 'B')[]):  ('A' | 'B');
//   (_: ('C' | 'D')[]):  ('C' | 'D');
// }` to function intersections which I'm trying to do here), I wrote the
// expected test value in `((...) => ...) & ((...) => ...)` form instead of
// overload type form like `{(...) => ...; (...) => ...}`
// https://github.com/microsoft/TypeScript/issues/48100
// https://github.com/microsoft/TypeScript/issues/27024

assert<Equals<ConvertTupleOfPossibleOptionsToOverloadsIntersection<['A' | 'B', 'C' | 'D']>,
  & (<const T extends ('A' | 'B')>(_: T[]) => T)
  & (<const T extends ('C' | 'D')>(_: T[]) => T)
>>
assert<Equals<ConvertTupleOfPossibleOptionsToOverloadsIntersection<['A'      , 'C' | 'D']>,
  & (<const T extends ('A'      )>(_: T[]) => T)
  & (<const T extends ('C' | 'D')>(_: T[]) => T)
>>
assert<Equals<ConvertTupleOfPossibleOptionsToOverloadsIntersection<[      'B', 'C' | 'D']>,
  & (<const T extends (      'B')>(_: T[]) => T)
  & (<const T extends ('C' | 'D')>(_: T[]) => T)
>>
assert<Equals<ConvertTupleOfPossibleOptionsToOverloadsIntersection<['A' | 'B', 'C'      ]>,
  & (<const T extends ('A' | 'B')>(_: T[]) => T)
  & (<const T extends ('C'      )>(_: T[]) => T)
>>
assert<Equals<ConvertTupleOfPossibleOptionsToOverloadsIntersection<['A'      , 'C'      ]>,
  & (<const T extends ('A'      )>(_: T[]) => T)
  & (<const T extends ('C'      )>(_: T[]) => T)
>>
assert<Equals<ConvertTupleOfPossibleOptionsToOverloadsIntersection<[      'B', 'C'      ]>,
  & (<const T extends (      'B')>(_: T[]) => T)
  & (<const T extends ('C'      )>(_: T[]) => T)
>>
assert<Equals<ConvertTupleOfPossibleOptionsToOverloadsIntersection<['A' | 'B',       'D']>,
  & (<const T extends ('A' | 'B')>(_: T[]) => T)
  & (<const T extends (      'D')>(_: T[]) => T)
>>
assert<Equals<ConvertTupleOfPossibleOptionsToOverloadsIntersection<['A'      ,       'D']>,
  & (<const T extends ('A'      )>(_: T[]) => T)
  & (<const T extends (      'D')>(_: T[]) => T)
>>
assert<Equals<ConvertTupleOfPossibleOptionsToOverloadsIntersection<[      'B',       'D']>,
  & (<const T extends (      'B')>(_: T[]) => T)
  & (<const T extends (      'D')>(_: T[]) => T)
>>

Example 1

Definition

const fruits = ['apple', 'orange', 'banana'] as const satisfies unknown[]; // to remove readonliness
const colors = ['red',   'green',  'blue'  ] as const satisfies unknown[]; // to remove readonliness

type FruitsUnion = typeof fruits[number];
type ColorsUnion = typeof colors[number];

const getRandomElementFromEitherArrayOfFruitsOrColors:
  ConvertTupleOfPossibleOptionsToOverloadsIntersection<[FruitsUnion, ColorsUnion]>
= <const T extends FruitsUnion[] | ColorsUnion[]>(arr: T) => {
  const doesArrHave = <T extends unknown[]>(valuesToLookFor: T) => arr.some((x) => valuesToLookFor.includes(x) )
  const doesArrHaveOnly = <T extends unknown[]>(valuesToLookFor: T) => arr.every((x) => valuesToLookFor.includes(x) )

  if (doesArrHave(fruits) && doesArrHave(colors))
    throw new Error('Mixing fruits with Colors is not allowed')

  if (!doesArrHave(fruits) && !doesArrHave(colors))
    throw new Error('getRandomElementFromEitherArrayOfFruitsOrColors has nothing to choose from.')

  if (
    (doesArrHave(fruits) && !doesArrHaveOnly(fruits))
    ||
    (doesArrHave(colors) && !doesArrHaveOnly(colors))
  )
    throw new Error("getRandomElementFromEitherArrayOfFruitsOrColors takes either only fruits or only colors")


  const randomElement = arr[Math.floor(Math.random() * arr.length)];
  return randomElement as T[number];
}

Tests and usage

// Maria likes apples more, so we increase it's probability, and she also
// hates oranges, so we remove them
const getRandomFruitForMaria =
  () => getRandomElementFromEitherArrayOfFruitsOrColors(['apple', 'apple', 'apple', 'banana']);

assert<Equals<typeof getRandomFruitForMaria, () => 'apple' | 'banana'>>;


// John has no preference for colors
const getRandomColorForJohn = () => getRandomElementFromEitherArrayOfFruitsOrColors(colors);

assert<Equals<typeof getRandomColorForJohn, () => 'red' | 'green' | 'blue'>>;


try {
  // @ts-expect-error Expected 1 arguments, but got 0.ts(2554)
  getRandomElementFromEitherArrayOfFruitsOrColors()
  console.error("Failed to fail :(")
} catch (error) {
  console.info('Successfully failed :)',error)
}


try {
  // @ts-expect-error No overload matches this call.
  //   Overload 1 of 2, '(x: ("apple" | "orange" | "banana")[]): "apple" | "orange" | "banana"', gave the following error.
  //   Type '"asd"' is not assignable to type '"apple" | "orange" | "banana"'.
  // Overload 2 of 2, '(x: ("red" | "green" | "blue")[]): "red" | "green" | "blue"', gave the following error.
  //   Type '"asd"' is not assignable to type '"red" | "green" | "blue"'.ts(2769)
  getRandomElementFromEitherArrayOfFruitsOrColors(['asd'])
  console.error("Failed to fail :(")
} catch (error) {
  console.info('Successfully failed :)',error)
}


// should not throw errors
getRandomElementFromEitherArrayOfFruitsOrColors(['apple'])


// should not throw errors
getRandomElementFromEitherArrayOfFruitsOrColors(['red'])


try {
  // @ts-expect-error No overload matches this call.
  //   Overload 1 of 2, '(x: ("apple" | "orange" | "banana")[]): "apple" | "orange" | "banana"', gave the following error.
  //   Type '"red"' is not assignable to type '"apple" | "orange" | "banana"'.
  // Overload 2 of 2, '(x: ("red" | "green" | "blue")[]): "red" | "green" | "blue"', gave the following error.
  //   Type '"apple"' is not assignable to type '"red" | "green" | "blue"'.ts(2769)
  getRandomElementFromEitherArrayOfFruitsOrColors(['apple', 'red'])
  console.error("Failed to fail :(")
} catch (error) {
  console.info('Successfully failed :)',error)
}


try {
  // @ts-expect-error No overload matches this call.
  //   Overload 1 of 2, '(x: ("apple" | "orange" | "banana")[]): "apple" | "orange" | "banana"', gave the following error.
  //   Type '"red"' is not assignable to type '"apple" | "orange" | "banana"'.
  // Overload 2 of 2, '(x: ("red" | "green" | "blue")[]): "red" | "green" | "blue"', gave the following error.
  //   Type '"apple"' is not assignable to type '"red" | "green" | "blue"'.ts(2769)
  getRandomElementFromEitherArrayOfFruitsOrColors(['red', 'apple'])
  console.error("Failed to fail :(")
} catch (error) {
  console.info('Successfully failed :)',error)
}

Example 2 (to show applicability to the OP's example)

Definition

const getSmallestElement: ConvertTupleOfPossibleOptionsToOverloadsIntersection<[string, number]> =
  <T extends string[] | number[]>(arr: T) => {
    const doesArrHaveValuesConforming = <T extends unknown[]>(rule: (t: unknown) => t is T[number]) => arr.some((x) => rule(x) )
    const doesArrHaveOnlyValuesConforming = <T extends unknown[]>(rule: (t: unknown) => t is T[number]) => arr.every((x) => rule(x) )
    const doesArrayHasStrings = doesArrHaveValuesConforming(t => typeof t === 'string');
    const doesArrayHasNumbers = doesArrHaveValuesConforming(t => typeof t === 'number');
    const doesArrayHasOnlyStrings = doesArrHaveOnlyValuesConforming(t => typeof t === 'string');
    const doesArrayHasOnlyNumbers = doesArrHaveOnlyValuesConforming(t => typeof t === 'number');

    if (doesArrayHasStrings && doesArrayHasNumbers)
      throw new Error('Mixing strings with numbers is not allowed')

    if (!doesArrayHasStrings && !doesArrayHasNumbers)
      throw new Error('getSmallestElement has nothing to choose from.')

    if (
      (doesArrayHasStrings && !doesArrayHasOnlyStrings)
      ||
      (doesArrayHasNumbers && !doesArrayHasOnlyNumbers)
    )
      throw new Error("getSmallestElement takes either only strings or only numbers")

    return arr.toSorted()[0] as T[number];
  }

Tests and usage

const smallestOf6Numbers =
  getSmallestElement([4, 0, 1, 2, 3, 4])

assert<Equals<typeof smallestOf6Numbers, 0 | 2 | 1 | 3 | 4>>(smallestOf6Numbers === 0);


const smallestOf6Strings =
  getSmallestElement(['4', '0', '1', '2', '3', '4'])

assert<Equals<typeof smallestOf6Strings, '0' | '1' | '2' | '3' | '4'>>(smallestOf6Strings === '0');


const smallestOf6Strings2 =
  getSmallestElement(['a', 'b', 'c', 'd', 'e', 'f'])

assert<Equals<typeof smallestOf6Strings2, 'a' | 'b' | 'c' | 'd' | 'e' | 'f'>>(smallestOf6Strings2 === 'a');


try {
  // @ts-expect-error Expected 1 arguments, but got 0.ts(2554)
  getSmallestElement()
  console.error("Failed to fail :(")
} catch (error) {
  console.info('Successfully failed :)',error)
}


try {
  // @ts-expect-error No overload matches this call.
  // Overload 1 of 2, '(x: string[]): string', gave the following error.
  //   Type 'boolean' is not assignable to type 'string'.
  // Overload 2 of 2, '(x: number[]): number', gave the following error.
  //   Type 'boolean' is not assignable to type 'number'.ts(2769)
  getSmallestElement([false])
  console.error("Failed to fail :(")
} catch (error) {
  console.info('Successfully failed :)',error)
}


// should not throw errors
getSmallestElement(['000'])


// should not throw errors
getSmallestElement([0])


try {
  // @ts-expect-error No overload matches this call.
  // Overload 1 of 2, '(x: string[]): string', gave the following error.
  //   Type 'number' is not assignable to type 'string'.
  // Overload 2 of 2, '(x: number[]): number', gave the following error.
  //   Type 'string' is not assignable to type 'number'.ts(2769)
  getSmallestElement(['000', 0])
  console.error("Failed to fail :(")
} catch (error) {
  console.info('Successfully failed :)',error)
}


try {
  // @ts-expect-error No overload matches this call.
  // Overload 1 of 2, '(x: string[]): string', gave the following error.
  //   Type 'number' is not assignable to type 'string'.
  // Overload 2 of 2, '(x: number[]): number', gave the following error.
  //   Type 'string' is not assignable to type 'number'.ts(2769)
  getSmallestElement([0, '000'])
  console.error("Failed to fail :(")
} catch (error) {
  console.info('Successfully failed :)',error)
}

@TheRealMkadmi
Copy link

TheRealMkadmi commented Oct 27, 2024

@nikelborm excellent answer.

here's a slightly more flexible version that would accept the render method as parameter:


export type TupleToUnion<T extends readonly unknown[]> = T[number];

export type UnionToIntersection<U> =
    (U extends any ? (_: U) => void : never) extends ((_: infer I) => void) ? I : never;

export type GenericFunction<
    Generics extends any[] = [],
    Args extends any[] = [],
    Return = any
> = <G extends Generics, A extends Args>(...args: A) => Return;

export type ConvertTupleOfPossibleOptionsToOverloadsUnion<
    TupleOfPossibleOptions extends readonly any[],
    Render extends GenericFunction<any, any, any>
> = TupleOfPossibleOptions extends [infer OneOfPossibleOptions, ...infer RestOfPossibleOptions]
    ? | Render
    | ConvertTupleOfPossibleOptionsToOverloadsUnion<RestOfPossibleOptions, Render>
    : never;

export type $overload<
    TFunc extends GenericFunction<any, any, any>,
    TAgainst extends readonly any[]
> = UnionToIntersection<
    ConvertTupleOfPossibleOptionsToOverloadsUnion<
        TAgainst,
        TFunc
    >
>

// Example usage:
declare type PossibleOptions = ['a', 'b', 'c'];
type RenderFindAll<OneOfPossibleOptions> = <const T extends OneOfPossibleOptions>(x: T) => T;
declare const method: $overload<RenderFindAll<TupleToUnion<PossibleOptions>>, PossibleOptions>;

const r = method('a'); // r is 'a'

@lgarron
Copy link

lgarron commented Oct 28, 2024

@nikelborm and @TheRealMkadmi: I don't have time to test out your implemenations, but it's cool to hear that this is already possible to some extent. Could I interest you in contributing to https://github.com/sindresorhus/type-fest/ so that it's easy for others to use your implementations? 😄

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests