0

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.

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

Typescript Playground

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

Attempt Playground


Clarification: The above is my attempt to enforce an error on the second element of the array. However, it does error, but for the wrong reason. Here's my original attempt where no error is given at all:

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

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

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

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

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

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 unless value is a number.
    //the passed `selector` property is type Selector<boolean, number>, therefor, the `value` should be a number
    //the second type of the selector property should match the type of value
  ],
});

Here's the Typescript Playground.

2
  • In "// should error since isn't a number", it does error. Can you make something that doesn't error but should? Otherwise I'm having a hard time following. Commented May 6, 2019 at 15:41
  • 1
    Thanks for taking a look! I added clarification above. Commented May 6, 2019 at 16:06

1 Answer 1

0

Ugh, this is exhausting. I don't know it's worth it to do this kind of thing. Existential types are the "correct" solution to this but the way they are encoded in TS would require you change your types to a more promise-like model (where instead of a value of type T you have a function which takes a callback that acts on a T).

Anyway, this part didn't change:

// unchanged:

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

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

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

And this part definitely did change:

// changed:

export interface Config<T, S, Result> {
  initialState?: T;
  // changed below, prefer inferring tuples over just arrays
  selectorsWithValue?: SelectorWithValue<S, Result>[] | [SelectorWithValue<S, Result>];
}

// drill down into a Config, and make sure value R is assignable to the R in Selector<S, R>
type ConfigOkay<C extends Config<any, any, any>> =
  C extends { selectorsWithValue?: infer A } ?
  A extends SelectorWithValue<any, any>[] ?
  {
    selectorsWithValue?: { [I in keyof A]: A[I] extends {
      selector: Selector<infer S, infer R1>, value: infer R2
    } ? { selector: Selector<S, R1>, value: R1 } : never }
  } : never : never;


export function createStore<C extends Config<T, S, any>, T = any, S = any>(
  config: C & ConfigOkay<C> = {} as any // assertion:
  // default value {} is not seen as C & ConfigOkay<C> I guess
): Store<T, S, any> {
  return new Store(config.initialState, config.selectorsWithValue);
}

And here it is in action.

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: 1 } // okay
  ],
});

createStore({
  selectorsWithValue: [
    { selector: selectBooleanFromString, value: false },
    { selector: selectNumberFromBoolean, value: "string" } // error!
  ],
});

Playground link

Yay? It's so complicated I'd maybe think you either want to shove this into a library where no mortal needs to look at it...


...or you might want to refactor to something else, say something that will brand a SelectorWithValue as "good", and then only accept "good" ones:

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

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

export interface GoodSelectorWithValue<S> {
  knownGood: true
  selector: Selector<S, any>;
  value: any
}

function vetSV<S, R>(x: SelectorWithValue<S, R>): GoodSelectorWithValue<S> {
  return Object.assign({ knownGood: true as true }, x);
}

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

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

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

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

createStore<any, any>({
  selectorsWithValue: [
    vetSV({ selector: selectBooleanFromString, value: false }),
    vetSV({ selector: selectNumberFromBoolean, value: 1 })
  ]
}); // okay

createStore<any, any>({
  selectorsWithValue: [
    vetSV({ selector: selectBooleanFromString, value: false }),
    vetSV({ selector: selectNumberFromBoolean, value: "string" }) // error!
  ]
}); 

Playground link

That might be better, where you require people to do more work to create a SelectorWithValue. Note how I had to specify <any, any> on createStore()... that's because it's expecting S to be a single thing like string or boolean, and not string | boolean, which is what it needs to be. So you might need some refactoring there to specify exactly what you're trying to constrain S to.

Hope that helps; good luck again.

Sign up to request clarification or add additional context in comments.

3 Comments

Sorry to exhaust you @jcalz! But I really appreciate your knowledge here. I'll happily buy you a coffee :) I'm starting to understand more using extends and infer to narrow acceptable type. I tried the first section of code by adding is to a new test.ts file and then compiling with tsc test.ts, but I get an error. I find it interesting the error is caught in the precompiler.
The error: Type '({ selector: Selector<string, boolean>; value: boolean; } | { selector: Selector<boolean, number>; value: number; })[]' is missing the following properties from type '[{ selector: Selector<string, boolean>; value: boolean; }, { selector: Selector<boolean, number>; value: number; }]': 0, 1
I added playground links to the code above to show it working. It's possible it only works as expected on TS3.4+, since that's what I'm using. It might need to be modified to work in earlier versions of TS, but I can't spend much more time on it at the moment.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.