1

In the code below, a StoreOp<"vanilla"> should be able to operate on a Store with "vanilla" as a flag, but which has other flags too. Currently the constraints are wrong, and a StoreOp<"vanilla"> can only work on a FlagStore<"vanilla"> when it should be able to also work on a FlagStore<"vanilla", "chocolate">.

Calls to setVanilla and setChocolate below currently have unwanted errors from this mismatch, and a successful solution would eliminate errors as commented against the four final lines of code.

An example error is like this...

Argument of type 'Store<{ vanilla: boolean; chocolate: boolean; raspberry: boolean; }>' is not assignable to parameter of type 'FlagStore<"vanilla">'.
  Types of property 'write' are incompatible.

How can I express constraints on the StoreOp generic function type, and the corresponding createSetterOp factory function so that any Store having the Operated flags AMONG its Stored flags is a valid late-bound argument to the StoreOp. Currently the constraint S extends FlagStore<Operated> is the wrong way round, but I'm hitting a dead end how to constrain it the other way around - that S includes Operated and MORE.

A StoreOp may rely on every one of its Operated flags having a corresponding boolean in the store, but shouldn't care what other flags are ALSO available. In terms of the StoreOp generic function definition, Operated should be assignable to Stored not the other way around. I am expecting the late-bound typing of the generic function against the actual store to ensure that a StoreOp implementation satisfies the Store's type when making an edit - e.g. it will be a compiler error unless it copies also the other (unknown) flags when writing back to the store.

In my wider API I need inference from both the Stored flags and the Operated flags to drive other typing, hence the crux being in this type widening of the StoreOp.

The code below is also in this Typescript Playground

interface Store<T>  {
  write: (state: T) => void;
  read: () => T;
}

type Flag = "vanilla" | "chocolate" | "raspberry";

type FlagStore<Stored extends Flag> = Store<{
  [flag in Stored]: boolean;
}>;

type StoreOp<Operated extends Flag> = <S extends FlagStore<Operated>>(
  store: S
) => void;

/** Create some stores */

function createStore<State>(state: State): Store<State> {
  const ref = { state };
  return {
    read: () => ref.state,
    write: (state: State) => ref.state = state,
  };
}

const fewStore = createStore({
  vanilla: false,
});

const manyStore = createStore({
  vanilla: false,
  chocolate: false,
  raspberry: false,
});

/** Prove store operations - seem correct */

// No errors as expected
fewStore.write({
  vanilla: true,
});

// No errors as expected
manyStore.write({
  vanilla: true,
  chocolate: false,
  raspberry: true
});


fewStore.write({
  vanilla: true,
  chocolate: false, // error here is correct - excess property
});

// error here is correct - missing property
manyStore.write({
  vanilla: true,
  chocolate: false,
});

/** STOREOP DEFINITION, USE AND ERRORS */

function createSetterOp<Operated extends Flag>(
  flag: Operated
): StoreOp<Operated> {
  return <S extends FlagStore<Operated>>(store: S) => {
    store.write({
      ...store.read(),
      [flag]: true,
    });
  };
}

const setVanilla = createSetterOp("vanilla");
const setChocolate = createSetterOp("chocolate");

setVanilla(manyStore); // this should NOT error - manyStore has extra keys but that's fine
setVanilla(fewStore); // this should NOT error - fewStore has the 'vanilla' key

setChocolate(manyStore); // this should NOT error - manyStore has extra keys but that's fine
setChocolate(fewStore); // this SHOULD error as the fewStore doesn't have the chocolate key
cefn
  • 2,895
  • 19
  • 28
  • `setVanilla(manyStore)` should only work if `manyStore` is a valid `FlagStore<"vanilla">`. And if `manyStore` is a valid `FlagStore<"vanilla">`, then I should be able to call `write({vanilla: true})`... *but* `manyStore` *requires* the other keys. So `manyStore` is an invalid `FlagStore<"vanilla">`, and therefore `setVanilla(manyStore)` should be an error. I don't understand how to resolve this unless it's okay for `FlagStore<"vanilla" | "chocolate">` to accept a `write()` argument missing some of those keys. Could you clear this up? – jcalz Mar 04 '23 at 23:31
  • I agree the type definition of `StoreOp` is dead wrong. However, given the implementation of `createSetterOp`, it would fulfil the runtime requirements of writing to a `FlagStore<"vanilla"|"chocolate">` correctly since it spreads from the original `state`, including all its properties, and then writes to the "vanilla" property (which it should only be able to do if there is a "vanilla" key in the store - hence the constraint needed). Currently the type definition of StoreOp doesn't allow this to happen since it over-constrains the types of Stores it will allow via the generic function. – cefn Mar 04 '23 at 23:36
  • Naively the constraint `S extends FlagStore` would instead be `Operated extends S` leading to a derived `Store` that has AT LEAST `Operated` among its keys. I don't know how to constrain a type to say it should allow assignment of another type, not be assignable to it (extended from it), in this context. – cefn Mar 04 '23 at 23:46
  • Ah I think I see, maybe you want `type StoreOp = (store: FlagStore) => void;` as shown [in this playground link](https://tsplay.dev/wQ2kYN). Does that meet your needs? If so, I'll write up an answer; if not, what am I missing? – jcalz Mar 04 '23 at 23:51
  • Initially that looks exactly like the answer I need. Certainly in this constrained case that does everything just right! I'll go back to the original case and examine the key union approach there. I think I was too busy trying to define T to dictate it should have U in it, and not thinking that I could focus on the `Store` definition instead as a site to compose both T and U! – cefn Mar 05 '23 at 00:02
  • Just checking: please let me know if I should write up an answer here or if you still need to check with your original code before you know. – jcalz Mar 05 '23 at 04:06
  • Some good news in the real system, (operators combine nicely) but I then ran into a problem. I've tried to map it back to this minimal repro correctly. [This playground link](https://tsplay.dev/wE7Xgm) extends the case a bit to demonstrate. The summary problem is that `const vanillaOp:StoreOp<"vanilla"> = setChocolate;` is not an error, even though setChocolate is type StoreOp<"chocolate">. This was a variation I was expecting and destroys type safety in the use case. Running the code in the playground shows a chocolate key added to a vanilla store :( – cefn Mar 05 '23 at 10:27
  • The real case is public but is much more confusing, and only partially complete because of typing issues like this. A starting point for understanding the real-world problem on the working branch is the [README](https://github.com/cefn/watchword/blob/100f16e16ac0eb1b69743d85068488ab75fc09a3/apps/interview/src/README.md) and I have begun copying across content from a previous version to the data (authoring which should draw heavily on inference) at [data.tsx](https://github.com/cefn/watchword/blob/22460a06b26696e1086b26917c532f305982aa5a/apps/interview/src/data.tsx) – cefn Mar 05 '23 at 11:35
  • Please put the use cases in the question itself as plaintext and not just as a playground link. – jcalz Mar 05 '23 at 17:12
  • TS uses an inaccurate shortcut to compare `StoreOp` to `StoreOp`. We can make it behave more accurately if we tell the compiler that `StoreOp` is *invariant* in `T`, via [variance annotations](//www.typescriptlang.org/docs/handbook/release-notes/typescript-4-7.html#optional-variance-annotations-for-type-parameters) as shown [in this playground link](//tsplay.dev/w6xrDm). – jcalz Mar 05 '23 at 17:21
  • ... I'm happy to write this up as an answer but I'm not particularly interested in chasing down any issue with composing these, at least not in this question, since that's out of scope for the question as asked and it's not fun to deal with questions whose scope expands in response to suggestions. Let me know. – jcalz Mar 05 '23 at 17:21
  • OMG I am learning so much from these interactions. I have never ever seen variance annotations so that is going to unlock even more power for me, thankyou. Yes, the minimal repro is an imperfect statement of the real problem, and as I proceed I'm finding 'leaks' in the expected typing behaviour, which only become obvious when a strategy has been settled on to stop the main leak, then I get blindsided by unexpected type behaviour elsewhere. The real problem is at the intersection of so many dimensions that it seems unfair to try and combine them all in a Stackoverflow question. – cefn Mar 05 '23 at 18:59
  • Yes I agree composition is out of scope, although the use of just `out` as a variance annotation looks like it fixed the problem anyway - the incorrect assignments are failing as they should and composition is also working as expected, with `const setCombination = combineOps(setVanilla, setChocolate)` having type `StoreOp<"vanilla" | "chocolate">` as I would expect. I am open to discovering some other place that has now sprung a typing leak as I proceed, I'm going to try just `out` as a variation and see where it takes me. Thanks so much. This case is solved! – cefn Mar 05 '23 at 19:16

1 Answers1

1

TypeScript doesn't have direct support for a lower bound constraint as requested in microsoft/TypeScript#14520, so you can't say V extends Flag super T. Luckily, TypeScript does have a union operator, so if you want that, you can just replace V with T | U where U extends Flag. So conceptually you want StoreOp to look like this:

type StoreOp<T extends Flag> =
  <U extends Flag>(store: FlagStore<T | U>) => void;

As you pointed out in the comments, though, the compiler fails to prevent you from assigning a StoreOp<X> to a StoreOp<Y> even when X and Y are incompatible. That's due to a shortcut the compiler takes when comparing two StoreOps. It seems to have decided that StoreOp<T> is bivariant in T, which it shouldn't be; I think it should probably be invariant in T, meaning that you can only assign a StoreOp<X> to a StoreOp<Y> if X is identical to Y. (See Difference between Variance, Covariance, Contravariance and Bivariance in TypeScript for more information about variance.)

I don't know why the compiler mismarked that type parameter, but luckily we can fix it using variance annotations:

type StoreOp<in out T extends Flag> =
  <U extends Flag>(store: FlagStore<T | U>) => void;

If you want it to be covariant or contravariant instead you can change those modifiers to just in or just out. But the idea is to guide the compiler so it correctly compares different StoreOps when it decides to take a shortcut.


Okay, let's try it:

setVanilla(manyStore); // okay
setVanilla(fewStore); // okay
setChocolate(manyStore); // okay
setChocolate(fewStore); // error

// error
const shouldFail: StoreOp<"chocolate"> = setVanilla; 

Looks good!

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • This is extraordinary type wizardry and exactly what I need. Thanks to the resolution of this minimal repro, my real-world project is unstalled too. All the constraints and auto-completion I was expecting when I constructed the type system are now working well. I didn't realise that Union was a workaround for missing `super` and that my expectations were for a non-default set of inferences, or even that this could be controlled, so this answer has opened up a lot of insights for me. – cefn Mar 05 '23 at 22:46