- Published on
- -6 min read
Intermediate Typescript: Generics and Mapped Types
Table of Contents
In the last post, I covered Literal and Union types. Those types are great and can get you a long way when writing your apps. When your codebase starts to grow you may find your middleware/helper functions still have too general of types which leads to more type casting than you would like. This is where generics and mapped types come in.
Generics
If your first interaction with generics was with java in school they may have put a bad taste in your mouth. Generics can be very simple and can help a lot with not repeating yourself. The simplest way to think of them is sort of like a function that takes types as parameters and yields back a new type. To drive that point home lets look at a few simple examples.
type type AddString<T> = string | T
AddString<function (type parameter) T in type AddString<T>
T> = function (type parameter) T in type AddString<T>
T | string;
type type NumOrString = string | number
NumOrString = type AddString<T> = string | T
AddString<number>; // yields number | string
type type StringOrString = string
StringOrString = type AddString<T> = string | T
AddString<string>; // yields just string
interface interface Box<T>
Box<function (type parameter) T in Box<T>
T> {
Box<T>.value: T
value: function (type parameter) T in Box<T>
T;
}
const const stringBox: Box<string>
stringBox: interface Box<T>
Box<string> = { Box<string>.value: string
value: 'aString' };
const const arrayNumBox: Box<number[]>
arrayNumBox: interface Box<T>
Box<number[]> = { Box<number[]>.value: number[]
value: [1, 2, 3] };
const const literalBox: Box<"aLiteral">
literalBox: interface Box<T>
Box<'aLiteral'> = { Box<"aLiteral">.value: "aLiteral"
value: 'aLiteral' };
Generics act as a template, you define a type using a type parameter (in these cases T
, but it can be any identifier), when the generic is used it would fill in all instances of T
with the passed in type.
The most likely instance you would run into generics is with functions. Generics in functions allows types to flow through it when the function does not really care about any specific type.
type type Nullable<T> = T | null
Nullable<function (type parameter) T in type Nullable<T>
T> = function (type parameter) T in type Nullable<T>
T | null;
function function getWithDefault<T>(possibleValue: Nullable<T>, defaultVal: T): T
getWithDefault<function (type parameter) T in getWithDefault<T>(possibleValue: Nullable<T>, defaultVal: T): T
T>(possibleValue: Nullable<T>
possibleValue: type Nullable<T> = T | null
Nullable<function (type parameter) T in getWithDefault<T>(possibleValue: Nullable<T>, defaultVal: T): T
T>, defaultVal: T
defaultVal: function (type parameter) T in getWithDefault<T>(possibleValue: Nullable<T>, defaultVal: T): T
T): function (type parameter) T in getWithDefault<T>(possibleValue: Nullable<T>, defaultVal: T): T
T {
if (possibleValue: Nullable<T>
possibleValue) {
// possibleValue is now T
return possibleValue: NonNullable<T>
possibleValue;
}
// returning a T when possibleValue is null
return defaultVal: T
defaultVal;
}
const const nullableNum: Nullable<number>
nullableNum: type Nullable<T> = T | null
Nullable<number> = 10;
const const num: number
num = function getWithDefault<number>(possibleValue: Nullable<number>, defaultVal: number): number
getWithDefault(const nullableNum: number
nullableNum, 2); // num is now just a number type
In this example, we use the same type T
3 times, as a Nullable<T>
, a default value, and the functions return type. Notice we do not need to say getWithDefault<number>(nullableNum,2)
, typescript infers that T should be set to number based on usage.
Generics to make types "flow"
Generics can help address a number of potential issues of types not "flowing" the way you want. A great example of this is a function modifying members of a union type. Recall the ApiEvent
type from the last post
interface LoginEvent {
LoginEvent.type: "login"
type: 'login';
LoginEvent.user: string
user: string;
LoginEvent.wasSuccessful: boolean
wasSuccessful: boolean;
}
interface PostCreatedEvent {
PostCreatedEvent.type: "postCreated"
type: 'postCreated';
PostCreatedEvent.name: string
name: string;
PostCreatedEvent.body: string
body: string;
PostCreatedEvent.createdAt: Date
createdAt: Date;
}
type type ApiEvent = LoginEvent | PostCreatedEvent
ApiEvent = LoginEvent | PostCreatedEvent;
Now let's say you are making a function that will take an event, and add a new field logged: boolean
to show the event was logged out. Your first attempt might look something like this.
function function addLogged(event: ApiEvent): ApiEvent & {
logged: boolean;
}
addLogged(event: ApiEvent
event: type ApiEvent = LoginEvent | PostCreatedEvent
ApiEvent): type ApiEvent = LoginEvent | PostCreatedEvent
ApiEvent & { logged: boolean
logged: boolean } {
return { ...event: ApiEvent
event, logged: boolean
logged: true };
}
This makes sense initially however, when you go to use this function you notice an issue.
const const loginEvent: LoginEvent
loginEvent: LoginEvent = { LoginEvent.type: "login"
type: 'login', LoginEvent.user: string
user: 'john', LoginEvent.wasSuccessful: boolean
wasSuccessful: true };
const const updated: ApiEvent & {
logged: boolean;
}
updated = function addLogged(event: ApiEvent): ApiEvent & {
logged: boolean;
}
addLogged(const loginEvent: LoginEvent
loginEvent);
var console: Console
console.Console.log(...data: any[]): void
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static)log(const updated: ApiEvent & {
logged: boolean;
}
updated.user);
Weird, it's obvious to you that all this function does is add on a field, why is typescript complaining that user
does not exist on PostCreatedEvent
? The issue is the function definition. Based on the types we pass in ApiEvent
and get back ApiEvent
with some extra stuff. To the type system we could just always return a PostCreatedEvent
with the logged field.
Generics help us tell typescript what we put in, is what we are going get out. Let's re-write this function like so.
function function addLogged<T extends ApiEvent>(event: T): T & {
logged: boolean;
}
addLogged<function (type parameter) T in addLogged<T extends ApiEvent>(event: T): T & {
logged: boolean;
}
T extends type ApiEvent = LoginEvent | PostCreatedEvent
ApiEvent>(event: T extends ApiEvent
event: function (type parameter) T in addLogged<T extends ApiEvent>(event: T): T & {
logged: boolean;
}
T): function (type parameter) T in addLogged<T extends ApiEvent>(event: T): T & {
logged: boolean;
}
T & { logged: boolean
logged: boolean } {
return { ...event: T extends ApiEvent
event, logged: boolean
logged: true };
}
const const loginEvent: LoginEvent
loginEvent: LoginEvent = { LoginEvent.type: "login"
type: 'login', LoginEvent.user: string
user: 'john', LoginEvent.wasSuccessful: boolean
wasSuccessful: true };
const const updated: LoginEvent & {
logged: boolean;
}
updated = function addLogged<LoginEvent>(event: LoginEvent): LoginEvent & {
logged: boolean;
}
addLogged(const loginEvent: LoginEvent
loginEvent);
var console: Console
console.Console.log(...data: any[]): void
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static)log(const updated: LoginEvent & {
logged: boolean;
}
updated.LoginEvent.user: string
user); // no error
Notice we did not change any logic of the function, just the type definition. We can still only pass in ApiEvent
s but when we pass in a specific LoginEvent
the type system knows we are only going to get back a LoginEvent
. The extends
keyword for generics is very powerful to restrict the possible allowed values for a function while still reasoning about specific types. This does not lose generality if you had a list of ApiEvents
you could still map over them with this function.
Generics in type definitions
Sometimes you may have a few wrapper types that hold the same types. This example is a little contrived, but I've run into this a few times before.
type type ValidValue = string | number
ValidValue = string | number;
interface WrapperOne {
WrapperOne.type: "wrapperOne"
type: 'wrapperOne';
WrapperOne.value: ValidValue
value: type ValidValue = string | number
ValidValue;
WrapperOne.info: string[]
info: string[];
}
interface WrapperTwo {
WrapperTwo.type: "wrapperTwo"
type: 'wrapperTwo';
WrapperTwo.value: ValidValue
value: type ValidValue = string | number
ValidValue;
WrapperTwo.extra: number
extra: number;
}
type type Wrapper = WrapperOne | WrapperTwo
Wrapper = WrapperOne | WrapperTwo;
In this example lets say the wrappers should both hold the same value type (both string or both number). But with this definition you could do
const const wrappedValues: Wrapper[]
wrappedValues: type Wrapper = WrapperOne | WrapperTwo
Wrapper[] = [
{
WrapperOne.type: "wrapperOne"
type: 'wrapperOne',
WrapperOne.value: ValidValue
value: 'aVal', // this wrapper is using a string
WrapperOne.info: string[]
info: ['extra info', 'more info'],
},
{
WrapperTwo.type: "wrapperTwo"
type: 'wrapperTwo',
WrapperTwo.value: ValidValue
value: 1, // this wrapper is using a number
WrapperTwo.extra: number
extra: 10,
},
];
Since the value
field can be string | number
there is nothing stopping a user of this type to mix and match the wrapped value types in the objects of the array.
Generics can be used to "lock" the value type in for all elements of the array.
type type ValidValue = string | number
ValidValue = string | number;
interface interface WrapperOne<T extends ValidValue>
WrapperOne<function (type parameter) T in WrapperOne<T extends ValidValue>
T extends type ValidValue = string | number
ValidValue> {
WrapperOne<T extends ValidValue>.type: "wrapperOne"
type: 'wrapperOne';
WrapperOne<T extends ValidValue>.value: T extends ValidValue
value: function (type parameter) T in WrapperOne<T extends ValidValue>
T;
WrapperOne<T extends ValidValue>.info: string[]
info: string[];
}
interface interface WrapperTwo<T extends ValidValue>
WrapperTwo<function (type parameter) T in WrapperTwo<T extends ValidValue>
T extends type ValidValue = string | number
ValidValue> {
WrapperTwo<T extends ValidValue>.type: "wrapperTwo"
type: 'wrapperTwo';
WrapperTwo<T extends ValidValue>.value: T extends ValidValue
value: function (type parameter) T in WrapperTwo<T extends ValidValue>
T;
WrapperTwo<T extends ValidValue>.info: number
info: number;
}
type type Wrapper<T extends ValidValue> = WrapperOne<T> | WrapperTwo<T>
Wrapper<function (type parameter) T in type Wrapper<T extends ValidValue>
T extends type ValidValue = string | number
ValidValue> = interface WrapperOne<T extends ValidValue>
WrapperOne<function (type parameter) T in type Wrapper<T extends ValidValue>
T> | interface WrapperTwo<T extends ValidValue>
WrapperTwo<function (type parameter) T in type Wrapper<T extends ValidValue>
T>;
Now when we say we have a Wrappers<string>
both wrapper's value
type will be string.
const const wrappedValues: Wrapper<string>[]
wrappedValues: type Wrapper<T extends ValidValue> = WrapperOne<T> | WrapperTwo<T>
Wrapper<string>[] = [
{
WrapperOne<string>.type: "wrapperOne"
type: 'wrapperOne',
WrapperOne<string>.value: string
value: 'aVal', // this wrapper is using a string
WrapperOne<string>.info: string[]
info: ['extra info', 'more info'],
},
{
WrapperTwo<string>.type: "wrapperTwo"
type: 'wrapperTwo',
WrapperTwo<string>.value: string
value: 'I can only use string', // using a number here would now throw an error
WrapperTwo<string>.info: number
info: 10,
},
];
Mapped Types
Mapped Types are a specific kind of generic types to help you build out new types. You may have seen is the Record<K,V>
type, this lets you define an object whose keys are in the type K
and values are in the type V
. You can define your own record type like so.
type type MyRecord<KeyType extends string, ValueType> = { [key in KeyType]: ValueType; }
MyRecord<function (type parameter) KeyType in type MyRecord<KeyType extends string, ValueType>
KeyType extends string, function (type parameter) ValueType in type MyRecord<KeyType extends string, ValueType>
ValueType> = {
[function (type parameter) key
key in function (type parameter) KeyType in type MyRecord<KeyType extends string, ValueType>
KeyType]: function (type parameter) ValueType in type MyRecord<KeyType extends string, ValueType>
ValueType;
};
const const myRecord: MyRecord<"foo" | "bar", string | number>
myRecord: type MyRecord<KeyType extends string, ValueType> = { [key in KeyType]: ValueType; }
MyRecord<'foo' | 'bar', number | string> = { foo: string | number
foo: 10, bar: string | number
bar: 'string' };
All mapped types do is iterate over possible values to define new keys (notice the key in KeyType
). Another common mapped type is Pick<T, Keys>
, this will yield a new type by picking the set of properties (Keys
) from T
. You can define it like so.
type type myPick<Type, Keys extends keyof Type> = { [key in Keys]: Type[key]; }
myPick<function (type parameter) Type in type myPick<Type, Keys extends keyof Type>
Type, function (type parameter) Keys in type myPick<Type, Keys extends keyof Type>
Keys extends keyof function (type parameter) Type in type myPick<Type, Keys extends keyof Type>
Type> = {
[function (type parameter) key
key in function (type parameter) Keys in type myPick<Type, Keys extends keyof Type>
Keys]: function (type parameter) Type in type myPick<Type, Keys extends keyof Type>
Type[function (type parameter) key
key];
};
// same record from the above example
type type OnlyFoo = {
foo: string | number;
}
OnlyFoo = type myPick<Type, Keys extends keyof Type> = { [key in Keys]: Type[key]; }
myPick<typeof const myRecord: MyRecord<"foo" | "bar", string | number>
myRecord, 'foo'>; // resulting type is {foo: number | string}
Typescript has a number of built-in Utility types, look over them all, they are extremely handy to avoid repeating yourself.
Incrementally Type an object
Mapped types are excellent for incrementally adding types when converting from javascript to typescript. Let's say you are adding types for database tables. When you first converted you may have made a type like type Tables = Record<string, any>
. This doesn't but you much other than saying Tables
is an object.
When you decide it's time to add types for tables you start by typing a simple database table like so
interface UserTable {
UserTable.id: number
id: number;
UserTable.username: string
username: string;
UserTable.email: string
email: string;
}
Now you run into an issue, how can you add just this type to the Tables
type we had before without specifying all your tables? Mapped types can set a default type for anything you have not explicitly set.
type type Tables = {
[key: string]: any;
users: UserTable;
}
Tables = {
users: UserTable
users: UserTable;
[key: string
key: string]: any;
};
type type Users = UserTable
Users = type Tables = {
[key: string]: any;
users: UserTable;
}
Tables['users']; // This is the UserTable type above
type type Other = any
Other = type Tables = {
[key: string]: any;
users: UserTable;
}
Tables['somethingElse']; // This is any
Now you can add types for just tables you have manually typed while allowing old code to still reference any untyped table.
Using Mapped Types instead of Enums
Typescript added Enums to Javascript, personally I would avoid them. There is some debate on how useful enums are, I will avoid that discussion here and show how what I do instead.
I like to use an as const
object to hold my enum like types.
const const UserStates: {
readonly guest: "guest";
readonly loggedIn: "loggedIn";
readonly paid: "paid";
}
UserStates = {
guest: "guest"
guest: 'guest',
loggedIn: "loggedIn"
loggedIn: 'loggedIn',
paid: "paid"
paid: 'paid',
} as type const = {
readonly guest: "guest";
readonly loggedIn: "loggedIn";
readonly paid: "paid";
}
const;
type type UserStatesMap = {
readonly guest: "guest";
readonly loggedIn: "loggedIn";
readonly paid: "paid";
}
UserStatesMap = typeof const UserStates: {
readonly guest: "guest";
readonly loggedIn: "loggedIn";
readonly paid: "paid";
}
UserStates;
type type UserStates = "guest" | "loggedIn" | "paid"
UserStates = type UserStatesMap = {
readonly guest: "guest";
readonly loggedIn: "loggedIn";
readonly paid: "paid";
}
UserStatesMap[keyof type UserStatesMap = {
readonly guest: "guest";
readonly loggedIn: "loggedIn";
readonly paid: "paid";
}
UserStatesMap]; // UserStates is now 'guest' | 'loggedIn' | 'paid'
function function handleState(state: UserStates): void
handleState(state: UserStates
state: type UserStates = "guest" | "loggedIn" | "paid"
UserStates) {}
function handleState(state: UserStates): void
handleState(const UserStates: {
readonly guest: "guest";
readonly loggedIn: "loggedIn";
readonly paid: "paid";
}
UserStates.guest: "guest"
guest);
Now this looks like more code than just using a typescript enum, but this buys us a few things. Since the type is just an object map we can use mapped types to modify/filter our types as we need. Let's say we wanted to filter our UserStates
type to not include guest
accounts. We could do the following
type type NonGuest = "loggedIn" | "paid"
NonGuest = {
[function (type parameter) key
key in keyof type UserStatesMap = {
readonly guest: "guest";
readonly loggedIn: "loggedIn";
readonly paid: "paid";
}
UserStatesMap]: function (type parameter) key
key extends 'guest' ? never : type UserStatesMap = {
readonly guest: "guest";
readonly loggedIn: "loggedIn";
readonly paid: "paid";
}
UserStatesMap[function (type parameter) key
key];
}[keyof type UserStatesMap = {
readonly guest: "guest";
readonly loggedIn: "loggedIn";
readonly paid: "paid";
}
UserStatesMap]; // This type resolves to `'loggedIn' | 'paid'`
In this example we actually used something called a Conditional Type to filter our the guest
type. These types basically provide the ternary operator to the typescript type system. In this example if the key extends 'guest'
its value in the resulting object would be never
. Then when we get all the values of the map the never
type is dropped from the union.
Conditional and Mapped types show up together a lot. They can be very powerful to modify existing types. They allow you to avoid redefining types for small changes.
I plan on covering Conditional Types in more detail in my next post!
Wrap up
Generics and Mapped Types are extremely powerful tools to help types follow through your program and to avoid repeating yourself. They can be a little confusing to newcomers so be sure to comment them well.