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;
};
}