I've often come across the requirement to 'patch' an object T through Object.assign().
For example during change propagation you may manipulate a stateful object which other code still has a reference to (typical of reactive programming, like MobX or Vue).
Of course it's important to ensure it still has a valid shape T at the end, since code elsewhere relies on it, and this has the danger of creating serious runtime errors.
So far, this has been done by brain, but I wonder how to get the Typescript compiler to help. I speculate that it is possible to define a Patch<T> type and a function applyPatch<T, P extends Patch<T>>(current: T, patch: P) which guarantees that the Patch will overwrite properties as necessary to ensure that the resulting object is still a T, whatever kind of T you started with.
The example below see typescript playground shows how you SHOULDN'T derive a Patch, since in combination with Object.assign it freely generates objects which are no longer T, but the compiler THINKS is a T. This will create runtime errors. In the code below, I never cast any object, so this is just an (inevitable?) Typescript soundness issue.
Can anyone suggest a definition of Patch<T> and applyPatch() that would ensure compilation errors on these unsound patch attempts.
The compiler should force the patch to have the correct properties - being explicit to overwrite any properties that might need aligning.
type DataLoader<Ok, Error = unknown> =
// loading true or false, but has nothing yet
| ({ loading: boolean } & {
data?: never;
errors?: never;
})
// retrieval failed - loading===false, has errors
| {
loading: false;
data?: never;
errors: Error[];
}
// retrieval succeeded - loading===false, has data
| {
loading: false;
data: Ok;
errors?: never;
};
/** Let's enumerate 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")],
});
/** Let's try to define a safe patch routine */
/** Bad example of patch definition (for demonstration) */
type Patch<T> = Partial<T>;
function applyPatch<T, P extends Patch<T>>(current: T, patch: P) {
return Object.assign(current, patch);
}
/** Now let's demonstrate how bad it is */
// in these examples, `loading` was set to `true`, but data or errors were left in place
// the patch should have been forced to set the `data` and `errors` values to `undefined`
// accepted because Object.assign is not smart enough and DataLoaderPatch<T> is too loose
const keptDataByMistake: DataLoader<Foo> = applyPatch(
createSuccessValue(),
{
loading: true,
}
);
const keptErrorsByMistake: DataLoader<Foo> = applyPatch(
createFailureValue(),
{
loading: true,
}
);
// here the loading value stayed as `true`, which is incompatible with data, errors
// the patch should have been forced to set the loading value to false
// accepted because Object.assign is not smart enough and DataLoaderPatch<T> is too loose
const successButStillLoadingMistake: DataLoader<Foo> = applyPatch(
createLoadingValue(),
{
data: { foo: "bar" },
}
);
const failureButStillLoadingMistake: DataLoader<Foo> = applyPatch(
createLoadingValue(),
{
errors: [new Error("Went wrong")],
}
);
/** Here we print out 4 examples of DataLoader<Foo> which are type-invalid, but were freely
* made without compiler errors using the above patching procedure
*/
for (const loader of [
keptErrorsByMistake,
keptDataByMistake,
successButStillLoadingMistake,
failureButStillLoadingMistake,
]) {
console.log(JSON.stringify(loader));
}

