1
votes

I'm looking to use generics to enforce that the type of val1 should match the type of val2 for each element in an array.

interface SameTypeContainer<T> {
  val1: T,
  val2: T;
}

test([
  {
    val1: 'string',
    val2: 'also string'
  },
  {
    val1: 5,
    val2: false // expect to throw error since type is not number
  }
]);

function test(_: SameTypeContainer<any>[]) { }

This does not cause an error. I expect this to raise a typescript error for the following reason:

In the second element of the array passed to the test function, val1 is a number and val2 is a string. The SameTypeContainer interface is supposed to enforce that the type of val1 matches the type of val2.

Next I tried to redefine the test function to use generics:

function test<T>(_: SameTypeContainer<T>[]) { }

Now I receive an error, but for the wrong reason. The compiler expects val1 to be of type string and val2 to be of type string, because that is how the first element in the array was defined.

I want each element in the array to be evaluated whether it satisfies the given generics independently.

Any help would be appreciated!


UPDATE:

Thanks for your help! I appreciate it! I'm starting to understand using extends, but having trouble expanding it to my actual use case:

export type Selector<S, Result> = (state: S) => Result;

export interface SelectorWithValue<S, Result> {
  selector: Selector<S, Result>;
  value: Result;
}

export interface Config<T, S, Result> {
  initialState?: T;
  selectorsWithValue?: SelectorWithValue<S, Result>[];
}

export function createStore<T = any, S = any, Result = any>(
  config: Config<T, S, Result> = {}
): Store<T, S, Result> {
  return new Store(config.initialState, config.selectorsWithValue);
}

export class Store<T, S, Result> {
  constructor(
    public initialState?: T,
    public selectorsWithValue?: SelectorWithValue<S, Result>[]
  ) {}
}

const selectBooleanFromString: Selector<string, boolean> = (str) => str === 'true';
const selectNumberFromBoolean: Selector<boolean, number> = (bool) => bool ? 1 : 0;

createStore({
  selectorsWithValue: [
    { selector: selectBooleanFromString, value: false },
    { selector: selectNumberFromBoolean, value: 'string' } // should error since isn't a number
  ],
});

Desired: for each element in the array passed to the createStore function, the second type of the selector should match the type of the value.

Ex: if the selector property is of type Selector<boolean, number>, the value property should be of type number, independent of what the other elements of the array's types.

Typescript Playground

Here's my first attempt modifying the Typescript playground @jcalz provided for the above nested use case:

Attempt Playground

3

3 Answers

0
votes

The reason why Array<SameTypeContainer<any>> doesn't work is because literally any value is assignable to any, so {val1: x, val2: y} will be of type SameTypeContainer<any> no matter what x and y are.


The type you're looking for is an array, where each element is some SameTypeContainer<T> type, but not any particular T. This is probably best expressed as an existential type like (possibly) Array<SameTypeContainer<exists T>>, which isn't currently supported natively in TypeScript (nor most other languages with generics). TypeScript (and most other languages with generics) only has universal types: someone who wants a value of type X<T> can specify any type for T that they want, and the provider of the value must be able to comply. An existential type is the opposite: someone who wants to provide a value of a type like X<exists T> can choose any specific type for T that they want, and the receiver of that value just has to comply. But, TypeScript doesn't have existential types, so we'll have to do something else.

(Well, it doesn't have native existential types. You can emulate them by using generic functions and inverting control via callbacks, but that's even more complicated to use than the solution I'm going to suggest next. If you're still interested in existentials you can read the linked article about it)


The next best thing we can do is to use generic type inference, by letting test() be a generic function accepting a parameter of generic type A which extends Array<SameContainer<any>>, and then verify that A matches the desired constraint. Here's one way we can do it:

interface SameTypeContainer<T> {
  val1: T;
  val2: T;
}

// IsSomeSameTypeContainerArray<A> will evaluate to A if it meets your constraint
// (it is an array where each element is a SameTypeContainer<T> for *some* T)
// Otherwise, if you find an element like {val1: T1, val2: T2} for two different 
// types T1, and T2, replace that element with the flipped version {val1: T2, val2: T1}    
type IsSomeSameTypeContainerArray<
  A extends Array<SameTypeContainer<any> >
> = {
  [I in keyof A]: A[I] extends { val1: infer T1; val2: infer T2 }
    ? { val1: T2; val2: T1 }
    : never
};

// test() is now generic in A extends Array<SameTypeContainer<any>>
// the union with [any] hints the compiler to infer a tuple type for A 
// _ is of type A & IsSomeSameTypeContainerArray<A>.  
// So A will be inferred as the type of the passed-in _,
// and then checked against A & IsSomeSameTypeContainerArray<A>.
// If it succeeds, that becomes A & A = A.
// If it fails on some element of type {val1: T1, val2: T2}, that element
// will be restricted to {val1: T1 & T2, val2: T1 & T2} and there will be an error
function test<A extends Array<SameTypeContainer<any>> | [any]>(
  _: A & IsSomeSameTypeContainerArray<A>
) {}


test([
  {
    val1: "string",
    val2: "also string"
  },
  {
    val1: 5,
    val2: 3
  },
  {
    val1: 3,  // error... not number & string!!
    val2: "4" // error... not string & number!!
  }
]);

Playground link

That works the way you want, I think. It's a bit complicated, but I mostly explained it inline. IsSomeSameTypeContainerArray<A> is a mapped array that uses conditional type inference on each element to convert {val1: T1, val2: T2} to {val1: T2, val2: T1}. If that transformation doesn't change the type of A, then everything's good. Otherwise there will be at least one element which doesn't match an element of swapped types, and there is an error.

Anyway, hope that helps; good luck!

0
votes

Whats going on is typescript is trying it's best to infer the type for you and because of that it's simply expanding the generic T to a union of string | number | boolean since these are the three possible types in the array.

What should typescript here? should it infer it from val1? val2? the number or boolean? the first reference ? or the last reference? There really is no "right" answer

To fix that you can do something like this..... although this isn't the only way . The "correct way" really depends on your program.

type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never;
type IsUnion<T> = [T] extends [UnionToIntersection<T>] ? false : true

interface SameTypeContainer<T> {
  val1: T,
  val2: T;
}

test([
  {
    val1: 'string',
    val2: 'also string'
  },
  {
    val1: "",
    val2: "false" // fine.
  }
]);

type PullTypeContainer<T extends SameTypeContainer<unknown>> =T extends SameTypeContainer<infer TEE> ? TEE : never

const test = <T extends SameTypeContainer<any>>(arg: (IsUnion<PullTypeContainer<T>> extends true ? "No unions" : T)[]) => {

}
0
votes

Since @jcalz brought it up, have some existential typing! I've already posted this answer, so I'll make this one CW. Other answers might be more idiomatic (thus better); but this one should be correct, in the sense that it is theoretically sound and should therefore be able handle whatever trickery is thrown at it.

You've got your parametric type:

interface SameTypeContainer<T> {
  val1: T,
  val2: T;
}

There exist "universal SameTypeContainer consumers", which have the following universally quantified type (parametrized by their return type)

type SameTypeConsumer<R> = <T>(c: SameTypeContainer<T>) => R

If you have a SameTypeContainer<T> but you don't know what T is, the only thing you can do with it is pass it into a SameTypeConsumer<R>, which doesn't care what T is, and get an R (which doesn't depend on T) back. So, a SameTypeContainer<T>-with-unknown-T is equivalent to a function that takes any consumer-that-doesn't-care-about-T and runs it on itself:

type SameType = <R>(consumer: SameTypeConsumer<R>) => R
           // = <R>(consumer: <T>(sameType: SameTypeContainer<T>) => R) => R

The end product is the ability to bury the type of a SameTypeContainer in the closure of an anonymous function. So, we've got a type and a value depending on that type stored in a data structure, the type of which only describes the relationship between the two. That's a dependent pair; we're done!

function sameType<T>(c: SameTypeContainer<T>): SameType {
     return <R>(consumer: SameTypeConsumer<R>) => consumer(c)
}

"Burying" the type like this allows you to inject SameTypeContainers of all different types into the one big union type SameType, which you can use as array elements, in your case.

let list: SameType[] = [ sameType({ val1: 'string', val2: 'also string' })
                       , sameType({ val1: 42, val2: 42 })
                       , sameType({ val1: {}, val2: {} })
                    // , sameType({ val1: 1, val2: false }) // error!
                       ]
function test(l: SameType[]): void {
  let doc = "<ol>"
  for(let s of l) {
    // notice the inversion
    let match = s(same => same.val1 === same.val2)
    doc += "<li>" + (match ? "Matches" : "Doesn't match") + "</li>"
  }
  doc += "</ol>"
  document.write(doc)
}
// it may be favorable to immediately destructure the pair as it comes into scope:
function test(l: SameType[]): void {
  let doc = "<ol>"
  for (let s0 of l) s0(s => {
    // this way, you can wrap the "backwardsness" all the way around your
    // code and push it to the edge, avoiding clutter.
    let match = s.val1 === s.val2 ? "Matches" : "Doesn't match"
    doc += "<li>" + match + "</li>"
  })
  doc += "</ol>"
  document.write(doc)
}

test(list)

This should output:

  1. Doesn't match
  2. Matches
  3. Doesn't match

You may find it useful to further define

function onSameType<R>(c: SameTypeConsumer<R>): (s: SameType) => R {
  return s => s(c)
}

So that you can apply functions in a "forwards" direction:

function someFunction<T>(c: SameTypeContainer<T>): R
let s: SameType
s(someFunction) // "backwards"
let someFunction2 = onSameType(someFunction)
someFunction2(s) // "forwards"