Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Typescript - Ensure Generic Property Exists On Generic Type With Descriptive Error

(Typescript newbie warning)

I am creating a reusable reducer which takes a state and an action and returns the state, but is limited to accepting state which contains a certain type at a given key. This key is passed as a parameter of a function. If the state object that is passed does not contain the key that is passed, the compiler should raise an error.

Now, I got this working. However the error message generated by the compiler does not adequately, in my estimation, describe the problem. It doesn't say that "x property is missing on type", instead it gives other errors, which I will detail below.

Types

// FetchAction up here, not so relevant...

export type FetchState = {
  status: FetchAction,
  timestamp: Date
} | null

export type LoginState = {
  token: string | null,
  fetching: FetchState
};

Base Reducer

const intialState: LoginState = {
  token: null,
  fetching: null
}

const loginReducer: Reducer<LoginState> = (state = intialState, action) => {
  //...other operations 
  return fetchingReducer(state, action, 'fetching');
}

Method 1

type FetchContainingState<S, K extends keyof S> = {
  [F in keyof S]: F extends K ? FetchState : S[F];
};

export const fetchingReducer = <S extends FetchContainingState<S, K>, K extends keyof S>(state: S, action: Action, key: K): S => {
  // implementation
}

This works properly. If I change the function call to: return fetchingReducer(state, action, 'fetchin'); (misspelling fetching), then I get this error:

Argument of type 'LoginState' is not assignable to parameter of type 'FetchContainingState'. Types of property 'token' are incompatible. Type 'string | null' is not assignable to type 'FetchState'. Type 'string' is not assignable to type 'FetchState'.

Well, it's good that it gives me an error. However, it just warns me about "token", or whatever other property exists on the object. It doesn't give me any direct indication as to which property it expects but doesn't exist.

Method 2

type EnsureFetchState<S, K extends keyof S> = S[K] extends FetchState ? S : never;

export const fetchingReducer = <S, K extends keyof S>(state: EnsureFetchState<S, K>, action: Action, key: K): S => {
   // implementation
}

This works as well, when I change the call to return fetchingReducer(state, action, 'fetchin'); (misspelling "fetching"), I get:

Argument of type 'LoginState' is not assignable to parameter of type 'never'.

More succinct, but even less descriptive of an error. It gives even less indication as to what could be wrong with the arguments that were passed.

Conclusion

In Method 1 I used a Mapped Type, and in Method 2 I used a Conditional Type to determine that the values for state and key that I passed did not pass the criteria we were searching for. However, in both cases the error messages do not really describe what the real problem is.

I'm new with more advanced types in Typescript, so there might be a really simple way of doing this or a simple concept that I've overlooked. I hope so! But anyways, the gist of this is: How can this type check on an object with a dynamic key be done more idiomatically, or in a way where the compiler generates a more beneficial error message?

like image 657
aaronofleonard Avatar asked Jan 27 '23 22:01

aaronofleonard


2 Answers

When describing type constraints, it usually helps to be as direct as possible. Constraint for a type S which has a property named K with type FetchState does not need to mention other properties:

export const fetchingReducer = <S extends {[k in K]: FetchState}, K extends string>(state: S, action: Action, key: K): S => {

This seems to produce desired error messages, at least with this example code (I just made up definitions for missing types to make it complete):

export interface Action {
    a: string;
}
export interface FetchAction extends Action {
    f: string;
}
export type FetchState = {
  status: FetchAction,
  timestamp: Date
} | null
export type LoginState = {
  token: string | null,
  fetching: FetchState
};
const intialState: LoginState = {
  token: null,
  fetching: null
}
export type Reducer<S> = (s: S, a: Action) => S;

const loginReducer: Reducer<LoginState> = (state = intialState, action) => {

  fetchingReducer(state, action, 'fetching'); // Argument of type 'LoginState' 
        // is not assignable to parameter of type '{ fetching: FetchState; }'.
        // Property 'fetching' is missing in type 'LoginState'.


  fetchingReducer(state, action, 'token'); // Argument of type 'LoginState' is 
          // not assignable to parameter of type '{ token: FetchState; }'.
          // Types of property 'token' are incompatible.
          // Type 'string | null' is not assignable to type 'FetchState'.
          // Type 'string' is not assignable to type 'FetchState'.


  // OK
  return fetchingReducer(state, action, 'fetching')
}

export const fetchingReducer = <S extends {[k in K]: FetchState}, K extends string>(state: S, action: Action, key: K): S => {
  return {} as S;
}
like image 99
artem Avatar answered Jan 29 '23 12:01

artem


The readability of the errors, much like beauty is in the eye of the beholder.

In my opinion, the prettiest error I can get is by adding a second overload where K is PropertyKey. The error is triggered by a conditional type, but the conditional type is added on the key parameter. The need of the second overload comes from the fact that if K extends keyof S and there is an error on key K will get inferred to keyof S instead of the actual value.

For the error part, I use a string literal type with a descriptive message. If you have a key named "This porperty is not of type FetchState" you might have an issue with this, but this seems unlikely.

type EnsureFetchState<S, K extends PropertyKey> = S extends Record<K, FetchState> ? {} : "This porperty is not of type FetchState";
export function fetchingReducer<S, K extends keyof S>(state: S, action: Action, key: K & EnsureFetchState<S, K>): S
export function fetchingReducer <S, K extends PropertyKey>(state: S, action: Action, key: K & EnsureFetchState<S, K>): S
export function fetchingReducer <S, K extends keyof S>(state: S, action: Action, key: K & EnsureFetchState<S, K>): S {
   // implementation
}

//Argument of type '"fetchin"' is not assignable to parameter of type '"fetchin" & "This porperty is not of type FetchState"'. 
return fetchingReducer(state, action, 'fetchin'); 
like image 29
Titian Cernicova-Dragomir Avatar answered Jan 29 '23 13:01

Titian Cernicova-Dragomir