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
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 StoreOp
s. 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 StoreOp
s 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!