Patrick Desjardins Blog
Patrick Desjardins picture from a conference

How to use TypeScript Satisfies Operator Keyword?

Posted on: 2023-03-17

My first reaction when satisfies operator launched with TypeScript 4.9 was that it was one more complex to understand in the language. I continued my way without spending much of my time understand the goal until one day I stumble into a scenario that was problematic for many years. TypeScript should do something about it! Then, I suddenly thought about satisfies and realized that it was the solution to my current and many years issue.

Preface: The Problematic Situation

To understand satisfies, you must feel the pain of not having the operator.

Example 1: Too Many Fields

Imagine you have a type that defines a Person and a function to add people into something.

interface Person {
    name: string;
    age: number;
}

declare function addPerson(p:Person):void;

In this example, we use declare to avoid getting into the detail of the function, as it is irrelevant to the point we are illustrating. What is important is how to call this function. Calling the function with a variable is the safest way without satisfies as the variable has an explicit type. For example:

interface Person {
    name: string;
    age: number;
}

declare function addPerson(p:Person):void;

const p: Person = {name:"Patrick", age: 89}; // Variable
addPerson(p); // Using the variable

The problem is that you must define an explicit variable. While it is acceptable in many cases, it would be, in some cases, more straightforward to remove that line.

interface Person {
    name: string;
    age: number;
}

declare function addPerson(p:Person):void;

addPerson({name:"Patrick", age: 89}); // Will be dangerous soon

The problem with the above code is that you may add fields later. As long as the minimum is there, TypeScript will allow passing the object because of its structural nature.

addPerson({name:"Patrick", age: 89, lastName:"Haha!"}); // lastName added, still pass the compilation time

The problem is that maybe the lastName was there in the past and removed. Then, you have a piece of code that should not be there anymore, but you are not warned. It might sound like a small problem so let's get into a second example.

Example 2: Union

Let's add more complexity. Let's have a function called add that accepts a union of two interfaces with no field that overlaps.

interface Person {
    name: string;
    age: number;
}

interface Animal {
    breathUnderWater: boolean;
}

declare function add(p:Person|Animal):void;

add({name:"Patrick", breathUnderWater:true}); // dangerous

This example compiles without problem. The function takes a union of a Person or Animal and it is legit to pass a mix of both because it fulfills the Animal part of the contract and the name is superfluous. The auto-complete in Visual Studio Code (VsCode) is also mixed up by the suggestion a mix of both types.

Before using TypeScript satisfies keyword, a trick was to use cast. For example, if we knew we wanted to pass a Person we could use as Person and the example above would be in error.

add({name:"Patrick", breathUnderWater:true} as Person); // dangerous

However, it opens another issue: it coerces the type to be a Person while it is not. So it means that maybe we are getting an error because we forgot some fields, in that case age, and adding the field fixes the compiler error.

add({name:"Patrick", age:123, breathUnderWater:true} as Person);

But, it remains that the breathUnderWater field should not be acceptable.

Example 3: Nested Implicit Object

Another example is if you have a list of nested objects that are not explicitly typed. For example, you may have an object with many constants or feature flags.

Here is a small example.

const MY_APP_CONSTANTS = {
    SIZES: {
        MIN: 1024
    },
    THRESHOLDS: {
        LOWER_BOUND: 0,
        UPPER_BOUND: 100
    },
    FEATURE_FLAGS: {
        ENABLE_POST_FORM: true
    }
};

The problem is that each field are number or boolean and it can be improved with as const so that each field has exactly only one possible value (the one defined in the object).

const MY_APP_CONSTANTS = {
    SIZES: {
        MIN: 1024
    },
    THRESHOLDS: {
        LOWER_BOUND: 0,
        UPPER_BOUND: 100
    },
    FEATURE_FLAGS: {
        ENABLE_POST_FORM: true
    }
} as const;

The const and as const works well, and trying to access an additional field that does not exist does not compile.

MY_APP_CONSTANTS.SIZES.MAX = 10000; // Compiler error, as expected.

So, there is no problem with example 3? There is. In that case, there is no type, and the implicit typing takes care of the issue. However, in a similar matter, we may have a type that will get into a similar problem of example 2, where we can access a non-existant field without compilation error.

Let's simplify the constant to have only the feature flag and to be open for additional flags. Later, we will allow any feature flag name as a string if the value is a boolean.

interface AppThresholds extends Record<string, boolean>{}

interface AppConfig {
    FEATURE_FLAGS: AppThresholds;
}

const MY_APP_CONSTANTS:AppConfig = {
    FEATURE_FLAGS: {
        ENABLE_POST_FORM: true
    }
};

So far, so good. However, a problem occurs when we use a type for the root of the object rather than the children.

interface AppFlags extends Record<string, boolean>{}

interface AppConfig {
    FEATURE_FLAGS: AppFlags;
}

const MY_APP_CONSTANTS: AppConfig = {
    FEATURE_FLAGS: {
        ENABLE_POST_FORM: true
    }
};

MY_APP_CONSTANTS.FEATURE_FLAGS.ENABLE_POST_FORM = true;
MY_APP_CONSTANTS.FEATURE_FLAGS.do_not_exist = true; // dangerous

The code compiles but do_not_exist does not exist. Using as const does not help.

const MY_APP_CONSTANTS: AppConfig = {
    FEATURE_FLAGS: {
        ENABLE_POST_FORM: true
    } as const
} as const;

The only way is not to type, which will have the constant to infer the type and not use the feature flag as a Record<string>, which means can be anything like do_not_exist.

const MY_APP_CONSTANTS = {
    FEATURE_FLAGS: {
        ENABLE_POST_FORM: true
    } 
}

However, the problem is that someone might add something else and change the goal of this object!

const MY_APP_CONSTANTS = {
    FEATURE_FLAGS: {
        ENABLE_POST_FORM: true
    },
    DO_NOT_ADD: { // Oh no! It does not respect the intended AppConfig contract
        SHOULD_NOT: true
    }
} 

Hence we are stuck between having a contract that forces the developer that block unintended additional fields in the interface at the expense of using fields that might not be there or having a strongly typed object that blocks the developer from adding fields outside the expected ones from the interface but allow them to access field not defined later.

Satisfies Role

In all three examples, the TypeScript operator satisfies fixes the issues.

Example 1: Too Many Fields

The first example showed that the function accepted too many fields.

addPerson({name:"Patrick", age: 89, lastName:"Haha!"} satisfied Person); // Compiler error

Using satisfies tells the compiler that the object must satisfies the contract of the Person interface, and it that case the field lastName is not part of the contract, thus causing a compilation error. satisfies is better than an as because it does not try to coerce the type to be casted to something that it does not. In this example, it would be cast because it has the minimum structural field to match the expected Person. Thus, as is not the solution.

Example 2: Union

Similarly, with example two with the union where a mix of the two desired type was compiled without a problem.

add({name:"Patrick", age:123, breathUnderWater:true} satisfies Person);

The code was working with as but we were adding a field that did not belong there. With satisfies the code does not compile as expected.

Example 3: Nested Implicit Object

The final example works well with satisfies if you do not type the object. The object will respect the contract or satisfy the interface by only allowing the developer to add FEATURE_FLAGS to the MY_APP_CONSTANTS object but will not allow the usage of the object to access fields that do not satisfy the feature flag object signature.

interface AppFlags extends Record<string, boolean>{}

interface AppConfig {
    FEATURE_FLAGS: AppFlags;
}

const MY_APP_CONSTANTS = {
    FEATURE_FLAGS: {
        ENABLE_POST_FORM: true
    } satisfies AppFlags,
    DO_NOT_ADD: { // Do not compile because we typed `AppConfig` to the object
       SHOULD_NOT: true
    }
} satisfies AppConfig; 

MY_APP_CONSTANTS.FEATURE_FLAGS.ENABLE_POST_FORM = true;
MY_APP_CONSTANTS.FEATURE_FLAGS.do_not_exist = true; // Do not compile

Conclusion

The TypeScript operator satisfies may not be needed in many prominent areas of your code base but should get into a reflect anytime you use cast or are into a situation where the code compiles by adding superfluous fields where it should not.

I have been using TypeScript since version 1.3-ish, and TypeScript seems to get bloated every year with new keywords or how keywords are used (for example, const or extends). The TypeScript operator satisfies fulfill a role to fix an issue with structural language and is worth learning and, to some extent worth bloating TypeScript a little more. While it would be great that some of these pitfalls be automatically fixed without explicitly marking the code with a keyword, it is currently the best solution to ensure type safety in a few scenarios.