Home > OS >  What is this TypeScript type doing?
What is this TypeScript type doing?

Time:09-12

So, I'm working through a pretty intense Typescript codebase and I'm running into an issue. I can't for the life of me decipher this code. I'm fairly new to Typescript and this one just gets me for some reason.

I make a JWTAuthPayload type like this.

type JWTAuthPayload = {
  [Types.Initial]: {
    isAuthenticated: boolean;
    user: AuthUser;
  };
  [Types.Login]: {
    user: AuthUser;
  };
  [Types.Logout]: undefined;
  [Types.Register]: {
    user: AuthUser;
  };
};

Then I make a JWTActions type. This is the result of (I'm assuming) some mapped type.

export type JWTActions = ActionMap<JWTAuthPayload>[keyof ActionMap<JWTAuthPayload>];

And here is the ActionMap type

export type ActionMap<M extends { [index: string]: any }> = {
  [Key in keyof M]: M[Key] extends undefined
    ? {
        type: Key;
      }
    : {
        type: Key;
        payload: M[Key];
      };
};

Can someone explain how this type works and how it relates to the JWTActions type?

CodePudding user response:

I moved your code to TS Playground if you want to check it.

First, ActionMap starts accepting a generic M and it is said that it should extend { [index: string]: any } (it's recommended to use Record<string, any> btw).

Then the fun part.

ActionMap is using Mapped Types to generate a new type. As per the docs:

A mapped type is a generic type which uses a union of PropertyKeys (frequently created via a keyof) to iterate through keys to create a type

This means that ActionMap is iterating over the keys of the generic M to create new keys in this new type, and this is this part:

[Key in keyof M]: ...

Which means that if you use it like this, for example:

type Test = {
    a: boolean,
    b: string,
};

type ActionMap<M extends Record<string, any>> = {
    [Key in keyof M]: number;
};

type Result = ActionMap<Test>;

Result will be

type Result = {
    a: number;
    b: number;
}

In the example, number is hardcoded so you focus on the keys, not the type of the keys.

Ok, so Mapped Types are just creating the keys, what about the types for those keys? For that, Conditional Types is being used. As per the docs:

Conditional types take a form that looks a little like conditional expressions (condition ? trueExpression : falseExpression) in JavaScript. When the type on the left of the extends is assignable to the one on the right, then you’ll get the type in the first branch (the “true” branch); otherwise you’ll get the type in the latter branch (the “false” branch).

If we expand our example a little like this:

type Test = {
    a: boolean,
    b: string,
};

type ActionMap<M extends Record<string, any>> = {
    [Key in keyof M]: M[Key] extends boolean ? number : undefined;
};

type Result = ActionMap<Test>;

Now the only key that will have the type number is a, because it extends boolean. So, Return is:

type Result = {
    a: number;
    b: undefined;
}

From your example, it's being checked whether M[Key] (or, the type assigned to the key, while iterating over the keys of M) extends undefined. The only case it does is when the key is [Types.Logout], the type returned for this case is:

{
    type: Key,
}

Which means that for Types.Logout the generated type is

Logout: {
    type: Types.Logout;
};

Now, if M[Key] does not extend undefined, it will return a more complex type, that uses both Key and M[Key]. For example, for [Types.Initial]:

{
    type: Key;
    payload: M[Key];
}

// becomes

Initial: {
    type: Types.Initial;
    payload: {
        isAuthenticated: boolean;
        user: AuthUser;
    };
};

Because while Key is Types.Initial (for this step of the iteration), M[Key] is the actual value, i.e. the type. So the whole type is assigned to payload.

All of this means that if you had:

type A = ActionMap<JWTAuthPayload>;

Type A would be:

type A = {
    Initial: {
        type: Types.Initial;
        payload: {
            isAuthenticated: boolean;
            user: AuthUser;
        };
    };
    Login: {
        type: Types.Login;
        payload: {
            user: AuthUser;
        };
    };
    Logout: {
        type: Types.Logout;
    };
    Register: {
        type: Types.Register;
        payload: {
            user: AuthUser;
        };
    };
}

But, in your code, you go a step further and you do:

ActionMap<JWTAuthPayload>[keyof ActionMap<JWTAuthPayload>];

Which uses Indexed Access Types. As per the docs:

type Person = { age: number; name: string; alive: boolean };
type Age = Person["age"];

// type Age = number

Which means that you can pluck the types by the name of the keys. If you provide multiple keys, you'll get a union type containing all the types. For example:

type Person = { age: number; name: string; alive: boolean };
type I = Person["age" | "name"];

// type I = string | number

By using keyof you can get all the types from all the keys. Like:

type Person = { age: number; name: string; alive: boolean };
type I = Person[keyof Person];

// type I = string | number | boolean

So, by using keyof ActionMap<JWTAuthPayload>, you are getting all the keys from the type I named A.
Then, by Indexed Access Types you use those keys to extract their types and combine everything into a union type. So, at the end of the day, JWTActions is:

type JWTActions = {
    type: Types.Initial;
    payload: {
        isAuthenticated: boolean;
        user: AuthUser;
    };
} | {
    type: Types.Login;
    payload: {
        user: AuthUser;
    };
} | {
    type: Types.Logout;
} | {
    type: Types.Register;
    payload: {
        user: AuthUser;
    };
}
  • Related