There have been instances where I needed to 'patch' an object T using Object.assign().
For instance, when propagating changes you might modify a stateful object that other code references (common in reactive programming like MobX or Vue).
It's crucial to ensure that the object still conforms to shape T at the end to prevent runtime errors as other code relies on it.
Up until now, this process has been manual, but I am curious about how the Typescript compiler can assist. My theory is that defining a Patch<T>
type and a
function applyPatch<T, P extends Patch<T>>(current: T, patch: P)
could guarantee that the resulting object remains a valid T regardless of its original form by overwriting properties as necessary.
The illustration linked below view typescript playground demonstrates incorrect derivation of a Patch, leading to objects that are no longer T when combined with Object.assign, yet the compiler incorrectly perceives them as such which may result in runtime errors. In the code snippet, no objects are forcibly converted, highlighting a potential TypeScript soundness flaw.
If anyone can propose a definition for Patch<T>
and applyPatch()
that would trigger compilation errors for unsound patch attempts, it would be beneficial.
The goal is for the compiler to mandate correct properties in the patch - explicitly overwriting any properties requiring alignment.
type DataLoader<Ok, Error = unknown> =
| ({ loading: boolean } & {
data?: never;
errors?: never;
})
| {
loading: false;
data?: never;
errors: Error[];
}
| {
loading: false;
data: Ok;
errors?: never;
};
/** Identifying the 4 strictly-allowed variants of DataLoader<Foo> */
interface Foo {
foo: "bar";
}
const createInitialValue = (): DataLoader<Foo> => ({ loading: false });
const createLoadingValue = (): DataLoader<Foo> => ({ loading: true });
const createSuccessValue = (): DataLoader<Foo> => ({
loading: false,
data: {
foo: "bar",
},
});
const createFailureValue = (): DataLoader<Foo> => ({
loading: false,
errors: [new Error("Went wrong")],
});
/** Attempting to define a safe patch routine */
/** Suboptimal patch definition for demonstration purposes */
type Patch<T> = Partial<T>;
function applyPatch<T, P extends Patch<T>>(current: T, patch: P) {
return Object.assign(current, patch);
}
/** Highlighting the shortcomings */
// These examples showcase inappropriate setting of `loading` while leaving data or errors intact
// The patch should have forced `data` and `errors` values to be set to `undefined`
// Accepted due to Object.assign limitations and loose DataLoaderPatch<T> constraints
const keptDataByMistake: DataLoader<Foo> = applyPatch(
createSuccessValue(),
{
loading: true,
}
);
const keptErrorsByMistake: DataLoader<Foo> = applyPatch(
createFailureValue(),
{
loading: true,
}
);
// Here, the loading value remains `true`, incompatible with data and errors
// The patch should have enforced setting the loading value to false
// Accepted due to Object.assign limitations and loose DataLoaderPatch<T> constraints
const successButStillLoadingMistake: DataLoader<Foo> = applyPatch(
createLoadingValue(),
{
data: { foo: "bar" },
}
);
const failureButStillLoadingMistake: DataLoader<Foo> = applyPatch(
createLoadingValue(),
{
errors: [new Error("Went wrong")],
}
);
/** Demonstrating creation of type-invalid DataLoader<Foo> without compiler errors using the patching procedure */
for (const loader of [
keptErrorsByMistake,
keptDataByMistake,
successButStillLoadingMistake,
failureButStillLoadingMistake,
]) {
console.log(JSON.stringify(loader));
}