Today, I want to share an interesting example of using the satisfies
operator in TypeScript. We will go through the evolution of a code example to see how we can type objects in a safer and more convenient way.
Table of contents
Open Table of contents
Initial Situation
Let’s start with a simple subscription cost calculation function, where we have several plans:
const plans = {
'basic': (duration: number, cost: number) => duration * cost,
'premium': (duration: number, cost: number) => duration * cost * 1.5,
'vip': (duration: number, cost: number) => duration * cost * 2.5
};
const calculate = (plan: string, duration: number, cost: number) => {
const costFormula = plans[plan];
return costFormula(duration, cost);
};
const totalCost = calculate('premium', 6, 20);
console.log(totalCost); //180
In this code, plans
contains functions, each of which takes duration
and cost
as parameters and returns the total cost.
However, there is an issue — the calculate
function accepts the plan
parameter as a string
, which is unsafe.
We could pass any value, such as 'gold'
, and it would cause a runtime error.
Adding Typing for Plans
To avoid errors, we can explicitly specify the types of available subscription plans:
type PlanType = 'basic' | 'premium' | 'vip';
const plans = {
'basic': (duration: number, cost: number) => duration * cost,
'premium': (duration: number, cost: number) => duration * cost * 1.5,
'vip': (duration: number, cost: number) => duration * cost * 2.5
};
const calculate = (plan: PlanType, duration: number, cost: number) => {
const costFormula = plans[plan];
return costFormula(duration, cost);
};
const totalCost = calculate('premium', 6, 20);
console.log(totalCost); //180
Now we have restricted the subscription plan type, and TypeScript ensures that only one of the values: 'basic'
, 'premium'
, or 'vip'
can be passed.
But adding a new plan requires modifying both the PlanType
type and the list of plans in the plans
object. Let’s fix that.
Using keyof typeof
To simplify, we can use keyof typeof
to automatically infer types based on the keys of the plans
object:
type PlanType = keyof typeof plans;
const plans = {
'basic': (duration: number, cost: number) => duration * cost,
'premium': (duration: number, cost: number) => duration * cost * 1.5,
'vip': (duration: number, cost: number) => duration * cost * 2.5,
'ultra': (duration: number, cost: number) => duration * cost * 3.5
};
Now the PlanType
type is automatically updated if we add new plans, such as 'ultra'
.
Using Record
for Typing the Object
We can explicitly type the plans
object using Record
to ensure type matching between keys and values. This is useful because Record
helps explicitly link plan types and functions, making the code more reliable and understandable. Thus, if a new plan is added, it will automatically be checked for type and function compatibility, reducing the likelihood of errors.
type PlanType = keyof typeof plans;
const plans: Record<string, (duration: number, cost: number) => number> = {
'basic': (duration, cost) => duration * cost,
'premium': (duration, cost) => duration * cost * 1.5,
'vip': (duration, cost) => duration * cost * 2.5,
'ultra': (duration, cost) => duration * cost * 3.5
};
This helps explicitly link plan types and functions, making the code more reliable and understandable. However, using Record<string, ...>
makes the type broader since keyof typeof plans
becomes string
. This means plans
can accept any string keys, requiring extra caution when adding new plans. We can revert PlanType
to an explicit union type, but that would require manually updating the type each time we add or change plans, which is less convenient.
Using satisfies
for Type Checking
Finally, we can use the satisfies
operator to let TypeScript check if the plans
object matches a specific type while still allowing type inference automatically:
type PlanType = keyof typeof plans;
const plans = {
basic: (duration, cost) => duration * cost,
premium: (duration, cost) => duration * cost * 1.5,
vip: (duration, cost) => duration * cost * 2.5,
ultra: (duration, cost) => duration * cost * 3.5,
} satisfies Record<string, (duration: number, cost: number) => number>;
const calculate = (plan: PlanType, duration: number, cost: number) => {
const costFormula = plans[plan];
return costFormula(duration, cost);
};
const totalCost = calculate('ultra', 6, 20);
console.log(totalCost);
The satisfies
operator allows us to check if the object matches the type Record<string, (duration: number, cost: number) => number>
, while still keeping type inference so that TypeScript automatically determines all object keys and their types. This makes the code more flexible and error-resistant.
In Simple Words…
In this article, we explored different ways to type a subscription plan object in TypeScript, making the code safer and more reliable. We started with a simple untyped version and gradually added more sophisticated typing strategies: using explicit types, keyof typeof
, Record
, and finally the satisfies
operator. Each step made the code more robust, ensuring that our subscription plans were correctly typed and reducing potential runtime errors. Using satisfies
was particularly helpful as it allowed us to keep automatic type inference while ensuring that the object conformed to a specific shape, making our code both flexible and type-safe.