Andrew Stegmaier
Andrew Stegmaier

Reputation: 3787

Is it possible to constrain the values of an object literal while still being able to infer the exact keys of that object later?

I'm running into a problem where trying to use a Typescript type with an index signature to constrain the values of an object literal destroys the ability, later on, to correctly infer the exact keys of that object. I'm wondering if there's something clever I could do to work around this (I'm not wedded to index signatures), or if I'm hitting a limitation of the language.

For context, I'm writing a function (createStore) that accepts an actions parameter that is an object containing some number of StoreAction functions as values. The createStore function returns a store object that contains (among other things) those same actions. When I use the store, I want TypeScript to yell at me if I call an an action that doesn't exist (i.e. it isn't a key on the actions object that was used to create the store).

You can see the example below in the TypeScript Playground.

Here is a simplified version of the createStore function:

type StoreAction<State> = (state: State) => State;

type ValuesAreActions<T, State> = {
  [key in keyof T]: StoreAction<State>;
};

function createStore<State, Actions extends ValuesAreActions<Actions, State>>(actions: Actions, state: State) {
  // In the real implementation, we're building the store, and binding the action functions to the store.
  return { actions, state };
}

Using it this way works fine:

type MyStoreState = { foo: string }

// Note: no type annotations on the "actions" object literal
const actions = {
  myAction1: (state: MyStoreState) => { /* do some modifications to state... */ return state },
}

const myStore = createStore(actions, { foo: "bar"});
myStore.actions.myAction1({ foo: "baz"}) // Works, as expected
myStore.actions.invalidAction({ foo: "baz"}) // TS Error, as expected

This does not work as I'd like:

// I attempted to write a type that constrains the value of "actions" object literal,
// so the user can get feedback inline if they make a mistake in the action function signatures.
type StoreActions<State> = {
  [name: string]: StoreAction<State>
}

// Note: unlike above, this object literal is given an index signature.
const actions2: StoreActions<MyStoreState> = {
  myAction1: state => { /* do some modifications to state... */ return state }
}

const myStore2 = createStore(actions2, { foo: "bar"})
myStore2.actions.myAction1({ foo: "baz"}) // Works, as expected
myStore2.actions.invalidAction({ foo: "baz"}) // No TS Error!

Upvotes: 0

Views: 99

Answers (2)

Andrew Stegmaier
Andrew Stegmaier

Reputation: 3787

It looks like TypeScript 4.9+ will have exactly the feature I was asking for - the satisfies operator (see blog post).

For example:

// This type is meant to constrain the value of the "actions" object (same as above)
type StoreActions<State> = {
  [name: string]: StoreAction<State>
}

// This is the "old" way of doing it (same as above). Assigning a general type to "actions2" hides the more specific aspects of the type.
const actions2: StoreActions<MyStoreState> = {
  myAction1: state => { /* do some modifications to state... */ return state }
}

// This uses the new "satisfies" operator to help catch errors on the object literal, while still enabling type inference down the line.
const actions3 = {
  myAction1: state => { /* do some modifications to state... */ return state }
} satisfies StoreActions<MyStoreState>;


const myStore2 = createStore(actions2, { foo: "bar"});
const myStore3 = createStore(actions3, { foo: "bar"});


myStore2.actions.myAction1({ foo: "baz"}) // Works, as expected.
myStore3.actions.myAction1({ foo: "baz"}) // Works, as expected.

// @ts-expect-error
myStore2.actions.invalidAction({ foo: "baz"}) // No TS Error!
// @ts-expect-error
myStore3.actions.invalidAction({ foo: "baz"}) // Property 'invalidAction' does not exist on type '{ myAction1: (state: MyStoreState) => MyStoreState; }'

See this example in the typescript playground

Upvotes: 1

Andrew Shepherd
Andrew Shepherd

Reputation: 45262

Not sure I totally understand what you're trying to do, but here's a 'throw spaghetti at a wall' suggestion..

Make the key type of StoreActions a generic parameter:

type StoreActions<KeyType extends string, State> = {
  [key in KeyType]: StoreAction<State>
}

Now pass the allowable keys as a generic parameter:

const actions2: StoreActions<
  "myAction1" | "myAction2",
  MyStoreState
> = {
  myAction1: state => { /* do some modifications to state... */ return state },
  myAction2: state => { /* I'm adding a second valid key here... */ return state }
}

playground link

Upvotes: 1

Related Questions