Strict Patch<T> type in Typescript

Viewed 48

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));
}
1 Answers

I've found an adequate workaround which might be the best answer, unless someone has a better idea.

Sadly it doesn't achieve the ideal of a SafePatch<T> type (a union type guaranteeing that Object.assign(someT, safePatch) is also a T). This would be great as it would facilitate auto-completion.

However, introducing explicit typing that tracks the merge taking place in Object.assign() is enough to achieve fairly meaningful compiler errors (assuming you know how patching can fail).

The explicit type is type Merged<Target, Source> = Omit<Target, keyof Source> & Source; then return Object.assign(orig, patch) as Merged<Orig, Patch>; returns sufficient type information to know that you may have made an object that isn't a T.

Compare the unsafePatch (simple Object.assign) with the safePatch (typed Object.assign) shown below (which you can explore at this playground).

correct detection of a bad patch

The error appears like this, which is just about traceable...

Error indicating a bad patch

The full demonstration is below.

function unsafePatch<Orig, Patch>(orig: Orig, patch: Patch) {
  return Object.assign(orig, patch);
}

function safePatch<Orig, Patch>(orig: Orig, patch: Patch) {
  return Object.assign(orig, patch) as Merged<Orig, Patch>;
}

const loader: DataLoader<string> = createSuccessValue();

{
  // unsafe version
  const badlyPatched: DataLoader<string> = unsafePatch(loader, {
    loading: true,
  });
  const wellPatched: DataLoader<string> = unsafePatch(loader, {
    loading: true,
    data: undefined,
    errors: undefined,
  });
}

{
  // safe version
  const badlyPatched: DataLoader<string> = safePatch(loader, {
    loading: true,
  });
  const wellPatched: DataLoader<string> = safePatch(loader, {
    loading: true,
    data: undefined,
    errors: undefined,
  });
}

function createInitialValue() {
  return { loading: false } as const;
}

function createLoadingValue() {
  return { loading: false } as const;
}

function createSuccessValue() {
  return {
    loading: false,
    data: "bar",
  } as const;
}

function createFailureValue() {
  return {
    loading: false,
    errors: [new Error("Went wrong")],
  } as const;
}

type Merged<Target, Source> = Omit<Target, keyof Source> & Source;

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;
    };

Related