Search code examples
typescriptvariable-assignmenttypescript-genericsextends

Widening store key constraints through a late-binding generic function


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

Solution

  • 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