diff --git a/README.md b/README.md index 41b6e7a..4656eb1 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,28 @@ `mergePartially` is a convenience method for overwriting only the values you want -## I see lots of TypeScript stuff. Can I use this in JavaScript too? + -Yes. Even though the examples are in TypeScript (since it helps to illustrate the problem that `mergePartially` solves), you can just remove the type annotations when using `mergePartially`. +- [Design Goals](#design-goals) +- [Why would you want to use this:](#why-would-you-want-to-use-this) + * [First, let's try to write the flexible factory function without mergePartially](#first-lets-try-to-write-the-flexible-factory-function-without-mergepartially) + * [Now let's refactor using mergePartially](#now-lets-refactor-using-mergepartially) +- [Examples](#examples) +- [F.A.Q. / Troubleshooting](#faq--troubleshooting) + * [Why wouldn't I just use Object.assign or the spread operator?](#why-wouldnt-i-just-use-objectassign-or-the-spread-operator) + * [I see lots of TypeScript stuff. Can I use this in JavaScript too?](#i-see-lots-of-typescript-stuff-can-i-use-this-in-javascript-too) + * [What's the difference between .deep and .shallow?](#whats-the-difference-between-deep-and-shallow) + * [Why is `.shallow` even necessary?](#why-is-shallow-even-necessary) + * [Why is my return type some strange error string?](#why-is-my-return-type-some-strange-error-string) +- [Contributions](#contributions) + + + +## Design Goals + +1. the resulting object will always be the same type/`interface` as the seed object +2. it will always be “Typescript first” so you know the type definitions will not differ at runtime (like many of this library's competitors) +3. all PRs should allow consumers of the library to feel confident to use this library in production and bullet-proof testing scenarios. High code-coverage percentages gaurantee this. ## Why would you want to use this: @@ -70,7 +89,7 @@ Wow look how much fewer lines and characters we have to write to accomplish the import { mergePartially, NestedPartial } from 'merge-partially'; function makeFakeUser(overrides?: NestedPartial): IUser { - return mergePartially( + return mergePartially.deep( { id: 1, age: 42, @@ -85,3 +104,63 @@ function makeFakeUser(overrides?: NestedPartial): IUser { ## Examples See [the unit tests](https://github.com/dgreene1/merge-partially/blob/master/src/index.spec.ts) for various examples. + +## F.A.Q. / Troubleshooting + +### Why wouldn't I just use Object.assign or the spread operator? + +These two functions have different goals. `Object.assign` can merge two different types into a combination type. `mergePartially` always returns the same type as the seed object. That's one of many reasons why `mergePartially` is safer than `Object.assign`. + +### I see lots of TypeScript stuff. Can I use this in JavaScript too? + +Yes. Even though the examples are in TypeScript (since it helps to illustrate the problem that `mergePartially` solves), you can just remove the type annotations when using `mergePartially`. + +### What's the difference between .deep and .shallow? + +- The main difference is that `.deep` allows you to pass multiple levels of partially supplied objects but `.shallow` only allows partial objects at the first level. + - On a more technical level, `.deep` allows you to pass in `NestedPartial` as where `.shallow` only accepts `Partial` +- Both will always return the full object + +For example: + +```ts +interface ISeed { + a: { + b: { + c: string; + d: string; + }; + }; +} + +const seed: ISeed = { + a: { + b: { + c: 'c', + d: 'd', + }, + }, +}; + +const deepResult = mergePartially.deep(seed, { a: { b: { d: 'new d' } } }); +const shallowResult = mergePartially.shallow(seed, { + a: { + b: { + c: 'I had to supply a value for c here but I did not have to supply it in .deep', + d: 'new d', + }, + }, +}); +``` + +### Why is `.shallow` even necessary? + +There are some data types that are "less-compatible" with the library and therefore require a workaround ([click here for the description](https://github.com/dgreene1/merge-partially/blob/master/whyShallowInstead.md)). It should be rare that you need to use `.shallow`, but you might prefer `.shallow` over `.deep` anyway for explicitness. + +### Why is my return type some strange error string? + +In order to meet the design goals (see above), mergePartially proactively prevents certain data combinations. See this link for more information on the soluton: [https://github.com/dgreene1/merge-partially/blob/master/whyShallowInstead.md](https://github.com/dgreene1/merge-partially/blob/master/whyShallowInstead.md) + +## Contributions + +PRs are welcome. To contribute, please either make a Github issue or find one you'd like to work on, then fork the repo to make the change. diff --git a/package-lock.json b/package-lock.json index c77cf58..fb242b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1749,6 +1749,12 @@ "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", "dev": true }, + "@types/faker": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@types/faker/-/faker-5.1.0.tgz", + "integrity": "sha512-7iK+rNvtSmG3FcAgI67BphmQVzBPkI6SCjJqR/SXxGr6tDUoMovkhGYIxNICEfy/trTgiGQL2v1tKPuFEXIrQg==", + "dev": true + }, "@types/glob": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.1.3.tgz", @@ -2070,6 +2076,15 @@ "type-fest": "^0.11.0" } }, + "ansi-red": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-red/-/ansi-red-0.1.1.tgz", + "integrity": "sha1-jGOPnRCAgAo1PJwoyKgcpHBdlGw=", + "dev": true, + "requires": { + "ansi-wrap": "0.1.0" + } + }, "ansi-regex": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", @@ -2086,6 +2101,12 @@ "color-convert": "^2.0.1" } }, + "ansi-wrap": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/ansi-wrap/-/ansi-wrap-0.1.0.tgz", + "integrity": "sha1-qCJQ3bABXponyoLoLqYDu/pF768=", + "dev": true + }, "any-observable": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/any-observable/-/any-observable-0.5.1.tgz", @@ -2267,6 +2288,15 @@ "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", "dev": true }, + "autolinker": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/autolinker/-/autolinker-0.28.1.tgz", + "integrity": "sha1-BlK0kYgYefB3XazgzcoyM5QqTkc=", + "dev": true, + "requires": { + "gulp-header": "^1.7.1" + } + }, "aws-sign2": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", @@ -3152,6 +3182,12 @@ "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", "dev": true }, + "coffee-script": { + "version": "1.12.7", + "resolved": "https://registry.npmjs.org/coffee-script/-/coffee-script-1.12.7.tgz", + "integrity": "sha512-fLeEhqwymYat/MpTPUjSKHVYYl0ec2mOyALEMLmzr5i1isuG+6jfI2j2d5oBO3VIzgUXgBVIcOT9uH1TFxBckw==", + "dev": true + }, "collection-visit": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz", @@ -3216,6 +3252,35 @@ "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", "dev": true }, + "concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "concat-with-sourcemaps": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/concat-with-sourcemaps/-/concat-with-sourcemaps-1.1.0.tgz", + "integrity": "sha512-4gEjHJFT9e+2W/77h/DS5SGUgwDaOwprX8L/gl5+3ixnzkVJJsZWDSelmN3Oilw3LNDZjZV0yqH1hLG3k6nghg==", + "dev": true, + "requires": { + "source-map": "^0.6.1" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, "configstore": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/configstore/-/configstore-5.0.1.tgz", @@ -3595,6 +3660,12 @@ "integrity": "sha1-9B8cEL5LAOh7XxPaaAdZ8sW/0+I=", "dev": true }, + "diacritics-map": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/diacritics-map/-/diacritics-map-0.1.0.tgz", + "integrity": "sha1-bfwP+dAQAKLt8oZTccrDFulJd68=", + "dev": true + }, "diff-sequences": { "version": "24.9.0", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-24.9.0.tgz", @@ -4369,6 +4440,57 @@ } } }, + "expand-range": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/expand-range/-/expand-range-1.8.2.tgz", + "integrity": "sha1-opnv/TNf4nIeuujiV+x5ZE/IUzc=", + "dev": true, + "requires": { + "fill-range": "^2.1.0" + }, + "dependencies": { + "fill-range": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-2.2.4.tgz", + "integrity": "sha512-cnrcCbj01+j2gTG921VZPnHbjmdAf8oQV/iGeV2kZxGSyfYjjTyY79ErsK1WJWMpw6DaApEX72binqJE+/d+5Q==", + "dev": true, + "requires": { + "is-number": "^2.1.0", + "isobject": "^2.0.0", + "randomatic": "^3.0.0", + "repeat-element": "^1.1.2", + "repeat-string": "^1.5.2" + } + }, + "is-number": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-2.1.0.tgz", + "integrity": "sha1-Afy7s5NGOlSPL0ZszhbezknbkI8=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + } + }, + "isobject": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", + "integrity": "sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=", + "dev": true, + "requires": { + "isarray": "1.0.0" + } + }, + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, "expect": { "version": "24.9.0", "resolved": "https://registry.npmjs.org/expect/-/expect-24.9.0.tgz", @@ -4518,6 +4640,12 @@ "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=", "dev": true }, + "faker": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/faker/-/faker-5.1.0.tgz", + "integrity": "sha512-RrWKFSSA/aNLP0g3o2WW1Zez7/MnMr7xkiZmoCfAGZmdkDQZ6l2KtuXHN5XjdvpRjDl8+3vf+Rrtl06Z352+Mw==", + "dev": true + }, "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4883,12 +5011,47 @@ "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==", "dev": true }, + "gray-matter": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-2.1.1.tgz", + "integrity": "sha1-MELZrewqHe1qdwep7SOA+KF6Qw4=", + "dev": true, + "requires": { + "ansi-red": "^0.1.1", + "coffee-script": "^1.12.4", + "extend-shallow": "^2.0.1", + "js-yaml": "^3.8.1", + "toml": "^2.3.2" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, "growly": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=", "dev": true }, + "gulp-header": { + "version": "1.8.12", + "resolved": "https://registry.npmjs.org/gulp-header/-/gulp-header-1.8.12.tgz", + "integrity": "sha512-lh9HLdb53sC7XIZOYzTXM4lFuXElv3EVkSDhsd7DoJBj7hm+Ni7D3qYbb+Rr8DuM8nRanBvkVO9d7askreXGnQ==", + "dev": true, + "requires": { + "concat-with-sourcemaps": "*", + "lodash.template": "^4.4.0", + "through2": "^2.0.0" + } + }, "har-schema": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", @@ -7410,6 +7573,15 @@ "package-json": "^6.3.0" } }, + "lazy-cache": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-2.0.2.tgz", + "integrity": "sha1-uRkKT5EzVGlIQIWfio9whNiCImQ=", + "dev": true, + "requires": { + "set-getter": "^0.1.0" + } + }, "lcov-parse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/lcov-parse/-/lcov-parse-1.0.0.tgz", @@ -7453,6 +7625,47 @@ "integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=", "dev": true }, + "list-item": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/list-item/-/list-item-1.1.1.tgz", + "integrity": "sha1-DGXQDih8tmPMs8s4Sad+iewmilY=", + "dev": true, + "requires": { + "expand-range": "^1.8.1", + "extend-shallow": "^2.0.1", + "is-number": "^2.1.0", + "repeat-string": "^1.5.2" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + }, + "is-number": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-2.1.0.tgz", + "integrity": "sha1-Afy7s5NGOlSPL0ZszhbezknbkI8=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + } + }, + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, "listr": { "version": "0.14.3", "resolved": "https://registry.npmjs.org/listr/-/listr-0.14.3.tgz", @@ -7703,6 +7916,12 @@ "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==", "dev": true }, + "lodash._reinterpolate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz", + "integrity": "sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=", + "dev": true + }, "lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -7721,6 +7940,25 @@ "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=", "dev": true }, + "lodash.template": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.template/-/lodash.template-4.5.0.tgz", + "integrity": "sha512-84vYFxIkmidUiFxidA/KjjH9pAycqW+h980j7Fuz5qxRtO9pgB7MDFTdys1N7A5mcucRiDyEq4fusljItR1T/A==", + "dev": true, + "requires": { + "lodash._reinterpolate": "^3.0.0", + "lodash.templatesettings": "^4.0.0" + } + }, + "lodash.templatesettings": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.templatesettings/-/lodash.templatesettings-4.2.0.tgz", + "integrity": "sha512-stgLz+i3Aa9mZgnjr/O+v9ruKZsPsndy7qPZOchbqk2cnTU1ZaldKK+v7m54WoKIyxiuMZTKT2H81F8BeAc3ZQ==", + "dev": true, + "requires": { + "lodash._reinterpolate": "^3.0.0" + } + }, "lodash.zip": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.zip/-/lodash.zip-4.2.0.tgz", @@ -7996,6 +8234,38 @@ "object-visit": "^1.0.0" } }, + "markdown-link": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/markdown-link/-/markdown-link-0.1.1.tgz", + "integrity": "sha1-MsXGUZmmRXMWMi0eQinRNAfIx88=", + "dev": true + }, + "markdown-toc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/markdown-toc/-/markdown-toc-1.2.0.tgz", + "integrity": "sha512-eOsq7EGd3asV0oBfmyqngeEIhrbkc7XVP63OwcJBIhH2EpG2PzFcbZdhy1jutXSlRBBVMNXHvMtSr5LAxSUvUg==", + "dev": true, + "requires": { + "concat-stream": "^1.5.2", + "diacritics-map": "^0.1.0", + "gray-matter": "^2.1.0", + "lazy-cache": "^2.0.2", + "list-item": "^1.1.1", + "markdown-link": "^0.1.1", + "minimist": "^1.2.0", + "mixin-deep": "^1.1.3", + "object.pick": "^1.2.0", + "remarkable": "^1.7.1", + "repeat-string": "^1.6.1", + "strip-color": "^0.1.0" + } + }, + "math-random": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/math-random/-/math-random-1.0.4.tgz", + "integrity": "sha512-rUxjysqif/BZQH2yhd5Aaq7vXMSx9NdEsQcyA07uEzIvxgI7zIr33gGsh+RU0/XjmQpCW7RsVof1vlkvQVCK5A==", + "dev": true + }, "mem": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/mem/-/mem-4.3.0.tgz", @@ -9450,6 +9720,12 @@ } } }, + "process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true + }, "progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -9598,6 +9874,25 @@ "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==", "dev": true }, + "randomatic": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/randomatic/-/randomatic-3.1.1.tgz", + "integrity": "sha512-TuDE5KxZ0J461RVjrJZCJc+J+zCkTb1MbH9AQUq68sMhOMcy9jLcb3BrZKgp9q9Ncltdg4QVqWrH02W2EFFVYw==", + "dev": true, + "requires": { + "is-number": "^4.0.0", + "kind-of": "^6.0.0", + "math-random": "^1.0.1" + }, + "dependencies": { + "is-number": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-4.0.0.tgz", + "integrity": "sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ==", + "dev": true + } + } + }, "rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -9707,6 +10002,21 @@ } } }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "realpath-native": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/realpath-native/-/realpath-native-1.1.0.tgz", @@ -9854,6 +10164,16 @@ } } }, + "remarkable": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/remarkable/-/remarkable-1.7.4.tgz", + "integrity": "sha512-e6NKUXgX95whv7IgddywbeN/ItCkWbISmc2DiqHJb0wTrqZIexqdco5b8Z3XZoo/48IdNVKM9ZCvTPJ4F5uvhg==", + "dev": true, + "requires": { + "argparse": "^1.0.10", + "autolinker": "~0.28.0" + } + }, "remove-trailing-separator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", @@ -10277,6 +10597,15 @@ "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", "dev": true }, + "set-getter": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/set-getter/-/set-getter-0.1.0.tgz", + "integrity": "sha1-12nBgsnVpR9AkUXy+6guXoboA3Y=", + "dev": true, + "requires": { + "to-object-path": "^0.3.0" + } + }, "set-value": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", @@ -10799,6 +11128,15 @@ "es-abstract": "^1.17.5" } }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, "strip-ansi": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", @@ -10822,6 +11160,12 @@ "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", "dev": true }, + "strip-color": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/strip-color/-/strip-color-0.1.0.tgz", + "integrity": "sha1-EG9l09PmotlAHKwOsM6LinArT3s=", + "dev": true + }, "strip-eof": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", @@ -11085,6 +11429,16 @@ "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", "dev": true }, + "through2": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "dev": true, + "requires": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + }, "tiny-glob": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.6.tgz", @@ -11164,6 +11518,12 @@ "repeat-string": "^1.6.1" } }, + "toml": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/toml/-/toml-2.3.6.tgz", + "integrity": "sha512-gVweAectJU3ebq//Ferr2JUY4WKSDe5N+z0FvjDncLGyHmIDoxgY/2Ie4qfEIDm4IS7OA6Rmdm7pdEEdMcV/xQ==", + "dev": true + }, "tough-cookie": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", @@ -11439,6 +11799,12 @@ "integrity": "sha512-OdjXJxnCN1AvyLSzeKIgXTXxV+99ZuXl3Hpo9XpJAv9MBcHrrJOQ5kV7ypXOuQie+AmWG25hLbiKdwYTifzcfQ==", "dev": true }, + "typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=", + "dev": true + }, "typedarray-to-buffer": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", @@ -11627,6 +11993,12 @@ "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", "dev": true }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", + "dev": true + }, "util.promisify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/util.promisify/-/util.promisify-1.0.1.tgz", @@ -11897,6 +12269,12 @@ "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==", "dev": true }, + "xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true + }, "y18n": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", diff --git a/package.json b/package.json index e5be244..604ec61 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,9 @@ "node": ">=10" }, "scripts": { + "update-toc": "markdown-toc -i README.md", "start": "tsdx watch", - "build": "tsdx build", + "build": "npm run update-toc && tsdx build", "test": "tsdx test", "test:coverage": "npm run test -- --coverage", "lint": "tsdx lint", @@ -42,8 +43,11 @@ "module": "dist/merge-partially.esm.js", "devDependencies": { "@types/clone-deep": "^4.0.1", + "@types/faker": "^5.1.0", "coveralls": "^3.1.0", + "faker": "^5.1.0", "husky": "^4.2.5", + "markdown-toc": "^1.2.0", "np": "^6.5.0", "tsdx": "^0.13.3", "tslib": "^2.0.1", diff --git a/src/conditionalTypes.ts b/src/conditionalTypes.ts new file mode 100644 index 0000000..7ac61d2 --- /dev/null +++ b/src/conditionalTypes.ts @@ -0,0 +1,35 @@ +import { SetDifference, Primitive } from 'utility-types'; + +export type ValueOf = T[keyof T]; + +type OptionalValues = { + [K in keyof T]-?: {} extends Pick ? T[K] : never; +}[keyof T]; +type OnlyOptionalValues = SetDifference, truthyNonCurlies>; + +export type NestedPartialWarningStr = 'mergePartially.deep does not allow a seed object to have values on it that are optional objects. Please use mergePartially.shallow instead. See https://github.com/dgreene1/merge-partially/blob/master/whyShallowInstead.md for more information.'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type truthyNonCurlies = number | Date | string | symbol | Set | any[] | (() => any) | null | undefined; + +export type NoNestedOptionalObjectsDeep = OnlyOptionalValues extends never + ? NestedPartialProblemPreventer + : NestedPartialWarningStr; +export type NoNestedOptionalOnObject = OnlyOptionalValues< + SetDifference, truthyNonCurlies> +> extends never + ? T + : NestedPartialWarningStr; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type NestedPartialProblemPreventer = T extends ((...args: any[]) => any) | Primitive + ? T + : T extends DeepNoNestedOptionalArray + ? DeepNoNestedOptionalArray + : T extends object + ? NoNestedOptionalOnObject + : T; + +/** @private */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +interface DeepNoNestedOptionalArray extends ReadonlyArray> {} diff --git a/src/index.spec.ts b/src/index.spec.ts index d331d00..03b8838 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -1,4 +1,6 @@ import { mergePartially, NestedPartial } from './index'; +import { NestedPartialWarningStr } from './conditionalTypes'; +import faker from 'faker'; interface IObjWithOptionalProperty { requiredProp: string; @@ -11,339 +13,573 @@ interface IObjWithANullProp { } describe('mergePartially', () => { - it('should overwrite a number even a falsy number (i.e. 0)', () => { - const original = { - a: 'a', - b: 1, - }; - - const result = mergePartially(original, { - b: 0, - }); + describe('supported scenarios', () => { + it('should overwrite a number even a falsy number (i.e. 0)', () => { + const seed = { + a: 'a', + b: 1, + }; - expect(result.b).toEqual(0); - // Prove that mergePartially is a pure function - expect(original.b).toEqual(1); - }); + const result = mergePartially.deep(seed, { + b: 0, + }); - it('should not replace missing properties, but should replace present properties with falsy values', () => { - interface ITestCase { - a: string; - b?: number; - c: string | null; - } - const original: ITestCase = { - a: 'a', - b: 2, - c: 'c', - }; - - const result = mergePartially(original, { - b: undefined, - c: null, + expect(result.b).toEqual(0); + // Prove that mergePartially is a pure function + expect(seed.b).toEqual(1); }); - expect(result).toEqual({ - a: 'a', - b: undefined, - c: null, + it('should overwrite a boolean even a falsy (i.e. false)', () => { + const original = { + a: 'a', + b: true, + }; + + const result = mergePartially.deep(original, { + b: false, + }); + + expect(result.b).toEqual(false); + // Prove that mergePartially is a pure function + expect(original.b).toEqual(true); }); - // Prove that mergePartially is a pure function - expect(original).toEqual({ - a: 'a', - b: 2, - c: 'c', + + it('should not replace missing properties, but should replace present properties with falsy values', () => { + interface ITestCase { + a: string; + b?: number; + c: string | null; + } + const seed: ITestCase = { + a: 'a', + b: 2, + c: 'c', + }; + + const result = mergePartially.deep(seed, { + b: undefined, + c: null, + }); + + expect(result).toEqual({ + a: 'a', + b: undefined, + c: null, + }); + // Prove that mergePartially is a pure function + expect(seed).toEqual({ + a: 'a', + b: 2, + c: 'c', + }); }); - }); - it('should overwrite a string even a falsy string', () => { - const original = { - a: 'a', - b: 'b', - }; + it('should overwrite a string even a falsy string', () => { + const seed = { + a: 'a', + b: 'b', + }; + + const result = mergePartially.deep(seed, { + b: '', + }); - const result = mergePartially(original, { - b: '', + expect(result.b).toEqual(''); + // Prove that mergePartially is a pure function + expect(seed.b).toEqual('b'); }); - expect(result.b).toEqual(''); - // Prove that mergePartially is a pure function - expect(original.b).toEqual('b'); - }); + it('should allow users to set a value to null if that is something the seed type allows', () => { + const seed: IObjWithANullProp = { + nonNullProp: 3, + nullableProp: 'is not initialized as null', + }; - it('should allow users to set a value to null if that is something the original type allows', () => { - const original: IObjWithANullProp = { - nonNullProp: 3, - nullableProp: 'is not initialized as null', - }; + const result = mergePartially.deep(seed, { + nonNullProp: 4, + nullableProp: null, + }); - const result = mergePartially(original, { - nonNullProp: 4, - nullableProp: null, + expect(result.nonNullProp).toEqual(4); + expect(result.nullableProp).toEqual(null); + // Prove that mergePartially is a pure function + expect(seed.nonNullProp).toEqual(3); + expect(seed.nullableProp).toEqual('is not initialized as null'); }); - expect(result.nonNullProp).toEqual(4); - expect(result.nullableProp).toEqual(null); - // Prove that mergePartially is a pure function - expect(original.nonNullProp).toEqual(3); - expect(original.nullableProp).toEqual('is not initialized as null'); - }); + it('should replace functions', () => { + const seed = { + foo: () => 'response of foo', + }; - it('should replace functions', () => { - const original = { - foo: () => 'response of foo', - }; + const result = mergePartially.deep(seed, { + foo: () => `response of foo's replacement`, + }); - const result = mergePartially(original, { - foo: () => `response of foo's replacement`, + expect(result.foo()).toEqual(`response of foo's replacement`); + // Prove that mergePartially is a pure function + expect(seed.foo()).toEqual('response of foo'); }); - expect(result.foo()).toEqual(`response of foo's replacement`); - // Prove that mergePartially is a pure function - expect(original.foo()).toEqual('response of foo'); - }); + it('is a pure function (i.e. it always returns a copy of the default) even when the override is not present (for convenience sake)', () => { + const original = { + a: 'a', + b: 'b', + }; - it('is a pure function (i.e. it always returns a copy of the default) even when the override is not present (for convenience sake)', () => { - const original = { - a: 'a', - b: 'b', - }; + const result = mergePartially.deep(original, undefined); - const result = mergePartially(original, undefined); + expect(original).toBe(original); + expect(result).not.toBe(original); + }); - expect(original).toBe(original); - expect(original).not.toBe(result); - }); - it('is a pure function (i.e. it always returns a copy of the default) even when the override has no values to merge', () => { - const original = { - a: 'a', - b: 'b', - }; + it('is a pure function (i.e. it always returns a copy of the default) even when the override has no values to merge', () => { + const seed = { + a: 'a', + b: 'b', + }; - const result = mergePartially(original, {}); + const result = mergePartially.deep(seed, {}); - expect(original).toBe(original); - expect(original).not.toBe(result); - }); + expect(seed).toBe(seed); + expect(seed).not.toBe(result); + }); + + it('should add a property that was not initially required', () => { + const seed: IObjWithOptionalProperty = { + requiredProp: 'some value', + }; - it('should add a property that was not initially required', () => { - const original: IObjWithOptionalProperty = { - requiredProp: 'some value', - }; + const result = mergePartially.deep(seed, { + optionalProp: "a value the seed obj didn't have", + }); - const result = mergePartially(original, { - optionalProp: "a value the original obj didn't have", + expect(result.optionalProp).toEqual("a value the seed obj didn't have"); + // Prove that mergePartially is a pure function + expect(seed.optionalProp).toEqual(undefined); }); - expect(result.optionalProp).toEqual("a value the original obj didn't have"); - // Prove that mergePartially is a pure function - expect(original.optionalProp).toEqual(undefined); - }); + it('supports nested objects (automatically with mergePartially.deep)', () => { + interface ITestCase { + a: string; + b: { + b1: string; + b2: string; + b3: { + b3a: string; + b3b: string; + b3c?: string; + }; + }; + c: string; + } + + const seed: ITestCase = { + a: 'a', + b: { + b1: 'b1', + b2: 'b2', + b3: { + b3a: 'b3a', + b3b: 'b3b', + b3c: undefined, + }, + }, + c: 'c', + }; - it('supports nested objects', () => { - interface ITestCase { - a: string; - b: { - b1: string; - b2: string; - b3: { - b3a: string; - b3b: string; - b3c?: string; + const override: NestedPartial = { + b: { + b2: 'new value for b2', + b3: { + b3a: undefined, + b3b: 'new value for b3b', + }, + }, + c: 'new c', + }; + + const result = mergePartially.deep(seed, override); + + // A 1st level object that isn't overriden should stay the same + expect(result.a).toEqual(seed.a); + // A 1st level value that is overriden should be updated + expect(result.c).toEqual('new c'); + // Prove that mergePartially is a pure function + expect(seed.c).toEqual('c'); + // A 2nd level value that isn't overriden should stay the same + expect(result.b.b1).toEqual('b1'); + // A 2nd level value that is overriden should be updated + expect(result.b.b2).toEqual('new value for b2'); + // Prove that mergePartially is a pure function (at 2nd level) + expect(seed.b.b2).toEqual('b2'); + // A 3rd level value that was originally supplied should be replaced with undefined if it is overriden + expect(result.b.b3.b3a).toEqual(undefined); + // A 3rd level value that is overriden should be updated + expect(result.b.b3.b3b).toEqual('new value for b3b'); + // Prove that mergePartially is a pure function (at 3rd level) + expect(seed.b.b3.b3b).toEqual('b3b'); + // A 3rd level value that isn't overriden should stay the same + expect(result.b.b3.b3c).toEqual(undefined); + }); + + it('supports nested objects (manually with mergePartially.shallow-- you can just used mergePartially.deep if you find this cumbersome, but be aware of the caveats described in whyShallowInstead.md)', () => { + interface ITestCase { + a: string; + b: { + b1: string; + b2: string; + b3: { + b3a: string; + b3b: string; + b3c?: string; + }; }; + c: string; + } + + const seed: ITestCase = { + a: 'a', + b: { + b1: 'b1', + b2: 'b2', + b3: { + b3a: 'b3a', + b3b: 'b3b', + b3c: undefined, + }, + }, + c: 'c', }; - c: string; - } - - const original: ITestCase = { - a: 'a', - b: { - b1: 'b1', - b2: 'b2', - b3: { - b3a: 'b3a', - b3b: 'b3b', - b3c: undefined, + + const result = mergePartially.shallow(seed, { + b: mergePartially.shallow(seed.b, { + b2: 'new value for b2', + b3: mergePartially.shallow(seed.b.b3, { + b3a: undefined, + b3b: 'new value for b3b', + }), + }), + c: 'new c', + }); + + // A 1st level object that isn't overriden should stay the same + expect(result.a).toEqual(seed.a); + // A 1st level value that is overriden should be updated + expect(result.c).toEqual('new c'); + // Prove that mergePartially is a pure function + expect(seed.c).toEqual('c'); + // A 2nd level value that isn't overriden should stay the same + expect(result.b.b1).toEqual('b1'); + // A 2nd level value that is overriden should be updated + expect(result.b.b2).toEqual('new value for b2'); + // Prove that mergePartially is a pure function (at 2nd level) + expect(seed.b.b2).toEqual('b2'); + // A 3rd level value that was originally supplied should be replaced with undefined if it is overriden + expect(result.b.b3.b3a).toEqual(undefined); + // A 3rd level value that is overriden should be updated + expect(result.b.b3.b3b).toEqual('new value for b3b'); + // Prove that mergePartially is a pure function (at 3rd level) + expect(seed.b.b3.b3b).toEqual('b3b'); + // A 3rd level value that isn't overriden should stay the same + expect(result.b.b3.b3c).toEqual(undefined); + }); + + it('should replace values even if they are optional', () => { + interface ITestCase { + a?: string; + b?: number; + c?: string[]; + d?: { + d1?: number; + d2?: bigint; + }; + e: { + e1?: number; + e2?: bigint; + }; + } + + const seed: ITestCase = { e: {} }; + + const result = mergePartially.deep(seed, { + a: 'replacement for a', + b: 2, + c: ['c1', 'c2'], + d: { + d1: 1, + d2: BigInt(100000000000000), + }, + e: { + e1: 1, + e2: BigInt(100000000000000), + }, + }); + + // Assert + expect(result).toMatchObject({ + a: 'replacement for a', + b: 2, + c: ['c1', 'c2'], + d: { + d1: 1, + d2: BigInt(100000000000000), }, - }, - c: 'c', - }; - - const override: NestedPartial = { - b: { - b2: 'new value for b2', - b3: { - b3a: undefined, - b3b: 'new value for b3b', + e: { + e1: 1, + e2: BigInt(100000000000000), }, - }, - c: 'new c', - }; - - const result = mergePartially(original, override); - - // A 1st level object that isn't overriden should stay the same - expect(result.a).toEqual(original.a); - // A 1st level value that is overriden should be updated - expect(result.c).toEqual('new c'); - // Prove that mergePartially is a pure function - expect(original.c).toEqual('c'); - // A 2nd level value that isn't overriden should stay the same - expect(result.b.b1).toEqual('b1'); - // A 2nd level value that is overriden should be updated - expect(result.b.b2).toEqual('new value for b2'); - // Prove that mergePartially is a pure function (at 2nd level) - expect(original.b.b2).toEqual('b2'); - // A 3rd level value that was originally supplied should be replaced with undefined if it is overriden - expect(result.b.b3.b3a).toEqual(undefined); - // A 3rd level value that is overriden should be updated - expect(result.b.b3.b3b).toEqual('new value for b3b'); - // Prove that mergePartially is a pure function (at 3rd level) - expect(original.b.b3.b3b).toEqual('b3b'); - // A 3rd level value that isn't overriden should stay the same - expect(result.b.b3.b3c).toEqual(undefined); + }); + // Ensure the function is pure + expect(seed).toEqual({ + e: {}, + }); + }); + + it('by default replaces objects wholesale (i.e. does not merge or append)', () => { + interface ITestCase { + a: string; + b: string[]; + c: string[]; + d: { + name: string; + }[]; + e?: number[]; + f: number[]; + } + + const seed: ITestCase = { + a: 'a', + b: ['b1', 'b2'], + c: ['c1', 'c2'], + d: [ + { + name: 'nestedObject1', + }, + { + name: 'nestedObject2', + }, + ], + f: [1, 2], + }; + + const result = mergePartially.deep(seed, { + b: ['b1 replacement'], + d: [ + { + name: 'nestedObject1 replacement', + }, + ], + e: [9, 9, 9, 9], + }); + + // Assert + expect(result).toEqual({ + a: 'a', + b: ['b1 replacement'], + c: ['c1', 'c2'], + d: [ + { + name: 'nestedObject1 replacement', + }, + ], + e: [9, 9, 9, 9], + f: [1, 2], + }); + // Ensure the function is pure + expect(seed).toEqual({ + a: 'a', + b: ['b1', 'b2'], + c: ['c1', 'c2'], + d: [ + { + name: 'nestedObject1', + }, + { + name: 'nestedObject2', + }, + ], + f: [1, 2], + }); + }); }); - it('should replace values even if they are optional', () => { - interface ITestCase { - a?: string; - b?: number; - c?: string[]; - d?: { - d1?: number; - d2?: bigint; + describe('caveat cases & unsupported scenarios', () => { + it('(CAVEAT) like all TypeScript functions, it unfortunately allows excess properties to be passed onward unless they are explicity or inline', () => { + const seed = { + a: 'a', + b: 'b', }; - e: { - e1?: number; - e2?: bigint; + const overrideObj = { + b: undefined, + c: 'hi I am an excess property value', }; - } - - const original: ITestCase = { e: {} }; - - const result = mergePartially(original, { - a: 'replacement for a', - b: 2, - c: ['c1', 'c2'], - d: { - d1: 1, - d2: BigInt(100000000000000), - }, - e: { - e1: 1, - e2: BigInt(100000000000000), - }, - }); - // Assert - expect(result).toMatchObject({ - a: 'replacement for a', - b: 2, - c: ['c1', 'c2'], - d: { - d1: 1, - d2: BigInt(100000000000000), - }, - e: { - e1: 1, - e2: BigInt(100000000000000), - }, - }); - // Ensure the function is pure - expect(original).toEqual({ - e: {}, + const result = mergePartially.deep(seed, overrideObj); + // NOTE: TypeScript will catch this error if you (a) use explicit types or (b) if you create the overrideObj inline. + // /* + // const result = mergePartially.deep(seed, {c: 'extra'}); + // */ + // Thankfully, the above code will error with "Argument of type '{ c: string; }' is not assignable to parameter of type 'Partial<{ a: string; b: string; }>'. + // Object literal may only specify known properties, and 'c' does not exist in type 'Partial<{ a: string; b: string; }>'.ts(2345)" + // This is because object literals have special workarounds for excess properties, but explicit types (and inline types) do not. + // Read more here: https://www.typescriptlang.org/docs/handbook/interfaces.html#excess-property-checks + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const resultWithMorePropsEvenThoughTsIsHidingIt = result as any; + expect(resultWithMorePropsEvenThoughTsIsHidingIt.c).toEqual('hi I am an excess property value'); }); - }); - it('by default replaces objects wholesale (i.e. does not merge or append)', () => { - interface ITestCase { - a: string; - b: string[]; - c: string[]; - d: { - name: string; - }[]; - e?: number[]; - f: number[]; - } - - const original: ITestCase = { - a: 'a', - b: ['b1', 'b2'], - c: ['c1', 'c2'], - d: [ - { - name: 'nestedObject1', - }, - { - name: 'nestedObject2', - }, - ], - f: [1, 2], - }; - - const result = mergePartially(original, { - b: ['b1 replacement'], - d: [ - { - name: 'nestedObject1 replacement', - }, - ], - e: [9, 9, 9, 9], + it('should not allow null as the base seed', () => { + // This is to fake out the type system so we can check the runtime assertions + const input = (null as unknown) as {}; + const scenario = () => mergePartially.deep(input, input); + expect(scenario).toThrowError('seed must be provided and must not be null/undefined. It was object'); }); - // Assert - expect(result).toEqual({ - a: 'a', - b: ['b1 replacement'], - c: ['c1', 'c2'], - d: [ - { - name: 'nestedObject1 replacement', - }, - ], - e: [9, 9, 9, 9], - f: [1, 2], + it('should not allow arrays as the base seed (at least at the time of this writing since it would make the return type very awkward)', () => { + const scenario = () => mergePartially.deep(['a'], ['a']); + expect(scenario).toThrowError('this function only supports non-array objects.'); }); - // Ensure the function is pure - expect(original).toEqual({ - a: 'a', - b: ['b1', 'b2'], - c: ['c1', 'c2'], - d: [ - { - name: 'nestedObject1', + + it('should error if an object has a value on it that is optional and is an object', () => { + interface IDeepObj { + userName: string; + preferences?: { + lastUpdated: Date; + favoriteColor?: string; + backupContact?: string; + }; + } + + const seed: IDeepObj = { + userName: 'Bob Smith', + }; + + const result = mergePartially.deep(seed, { preferences: { favoriteColor: 'blue' } }); + + // ASSERT + // The next group of assertions are essentially type-checking unit test + // The next line ensures that the resolved type was not never + let ensureItsNotNever = result.toString(); + // We set the variable to itself to avoid "'ensureItsNotNever' is declared but its value is never read.ts(6133)" + ensureItsNotNever = ensureItsNotNever; + // The next line will literally not compile if the result at compile time is not the warning string. + let assertion: NestedPartialWarningStr = result; + // We set the variable to itself to avoid "'assertion' is declared but its value is never read.ts(6133)" + assertion = assertion; + // We check this because this scenario allows for an unforunate runtime result that does not match the compile time type (unless we intervened... which we do now) + // Basically result.preferences.lastUpdated would be undefined at runtime but a string at compile time. + + // WORKAROUND via .shallow + // We have to get around "Property 'lastUpdated' is missing in type '{ favoriteColor: string; }' but required in type '{ lastUpdated: Date; favoriteColor: string; backupContact?: string | undefined; }'.ts(2345)" + // Notice that override is a Partial not a NestedPartial + const mockUserFactory = (override: Partial): IDeepObj => { + return mergePartially.shallow(seed, override); + }; + const resultOfWorkaround = mockUserFactory({ + preferences: { + lastUpdated: new Date('1999-01-01T12:00:00.000Z'), + favoriteColor: 'blue', }, - { - name: 'nestedObject2', + }); + + expect(resultOfWorkaround).toEqual({ + preferences: { + favoriteColor: 'blue', + lastUpdated: new Date('1999-01-01T12:00:00.000Z'), }, - ], - f: [1, 2], + userName: 'Bob Smith', + }); }); - }); - it('(CAVEAT) like all TypeScript functions, it unfortunately allows excess properties to be passed onward unless they are explicity or inline', () => { - const original = { - a: 'a', - b: 'b', - }; - const overrideObj = { - b: undefined, - c: 'hi I am an excess property value', - }; - - const result = mergePartially(original, overrideObj); - // NOTE: TypeScript will catch this error if you (a) use explicit types or (b) if you create the overrideObj inline. - // /* - // const result = mergePartially(original, {c: 'extra'}); - // */ - // Thankfully, the above code will error with "Argument of type '{ c: string; }' is not assignable to parameter of type 'Partial<{ a: string; b: string; }>'. - // Object literal may only specify known properties, and 'c' does not exist in type 'Partial<{ a: string; b: string; }>'.ts(2345)" - // This is because object literals have special workarounds for excess properties, but explicit types (and inline types) do not. - // Read more here: https://www.typescriptlang.org/docs/handbook/interfaces.html#excess-property-checks - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const resultWithMorePropsEvenThoughTsIsHidingIt = result as any; - expect(resultWithMorePropsEvenThoughTsIsHidingIt.c).toEqual('hi I am an excess property value'); + it('should error if an SECOND LEVEL object has a value on it that is optional and is an object', () => { + interface IAddress { + street: string; + city: string; + state: string; + zipCode: string; + } + interface IDeepObj { + userName: string; + preferences?: { + lastUpdated: Date; + favoriteColor?: string; + backupContact?: string; + mailingAddress?: IAddress; + }; + } + + const seed: IDeepObj = { + userName: 'Bob Smith', + }; + + const override = { preferences: { favoriteColor: 'blue' } }; + + const result = mergePartially.deep(seed, override); + + // ASSERT + // The next group of assertions are essentially type-checking unit test + // The next line ensures that the resolved type was not never + let ensureItsNotNever = result.toString(); + // We set the variable to itself to avoid "'ensureItsNotNever' is declared but its value is never read.ts(6133)" + ensureItsNotNever = ensureItsNotNever; + // The next line will literally not compile if the result at compile time is not the warning string. + let assertion: NestedPartialWarningStr = result; + // We set the variable to itself to avoid "'assertion' is declared but its value is never read.ts(6133)" + assertion = assertion; + // We check this because this scenario allows for an unforunate runtime result that does not match the compile time type (unless we intervened... which we do now) + // Basically result.preferences.lastUpdated would be undefined at runtime but a string at compile time. + + // WORKAROUND via multiple .shallow calls via multiple factory functions + + // ##### NOTE ##### + // The following is an example specifically designed to demonstrate factory functions... + // ...since this is the most realistic case where you would want to be explicit about nested objects + // ##### END NOTE #### + + // Arrange + const mockAddressFactory = (override: Partial): IAddress => { + // If no override is provided, this totally random object will be returned. + // The randomness ensures that tests are not tightly coupled to specific data + // ...unless they want to have specific data... in which, case they have to provide that data upon calling the factory funtion + const addressSeed: IAddress = { + street: faker.address.streetAddress(), + city: faker.address.city(), + state: faker.address.stateAbbr(), + zipCode: faker.address.zipCode(), + }; + return mergePartially.shallow(addressSeed, override); + }; + + const mockPreferencesFactory = (override: Partial): IDeepObj['preferences'] => { + const preferncesSeed: IDeepObj['preferences'] = { + lastUpdated: faker.date.future(), + backupContact: faker.phone.phoneNumber(), + favoriteColor: faker.random.word(), + }; + return mergePartially.shallow(preferncesSeed, override); + }; + + const mockUserFactory = (override: Partial): IDeepObj => { + const userSeed: IDeepObj = { + userName: faker.name.firstName() + ' ' + faker.name.lastName(), + }; + return mergePartially.shallow(userSeed, override); + }; + + // Act + const resultOfWorkaround = mockUserFactory({ + preferences: mockPreferencesFactory({ + mailingAddress: mockAddressFactory({ + state: 'NY', + }), + }), + }); + + // Assert + // This would fail and state would be undefined if our factory functions were improperly set up, or or if mergePartially was misbehaving + expect(resultOfWorkaround.preferences?.mailingAddress?.state).toEqual('NY'); + }); }); }); diff --git a/src/index.ts b/src/index.ts index 5478932..a045169 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import deepClone from 'clone-deep'; import { DeepPartial } from 'utility-types'; import { assertNever } from './helpers/assertIs'; +import { NoNestedOptionalObjectsDeep } from './conditionalTypes'; export type NestedPartial = DeepPartial; @@ -22,7 +23,17 @@ const toKVP = , K extends keyof T>(object: T): Array unknown; -type AllBasics = number | string | undefined | null | bigint | symbol | Callable | Record; +type AllBasics = + | number + | string + | undefined + | Date + | boolean + | null + | bigint + | symbol + | Callable + | Record; type AllPossibleValues = AllBasics | Array; type ARecordOfAllPossible = Record; @@ -39,6 +50,7 @@ const determineNewValue = (input: { typeof newValue === 'number' || typeof newValue === 'bigint' || typeof newValue === 'function' || + typeof newValue === 'boolean' || typeof newValue === 'symbol' ) { // There's nothing to iterate over, so return the override @@ -56,7 +68,7 @@ const determineNewValue = (input: { return { keyToOverride, newValue }; } // eslint-disable-next-line @typescript-eslint/no-use-before-define - return { keyToOverride, newValue: mergePartially(oldValue, newValue) }; + return { keyToOverride, newValue: mergePartiallyShallow(oldValue, newValue) }; } else if (newValue === undefined) { // But if the consumer provided the property, they probably wanted to replace the value with undefined. And if we're wrong then TypeScript will have caught this. return { keyToOverride, newValue }; @@ -71,18 +83,23 @@ const determineNewValue = (input: { * @param seed the object that is used establish the start of what you want the result to look like. This is the object that will be overriden before a result is produced * @param override the data that will be used when replacing the seed's key/values */ -export const mergePartially = >( - seed: T1, - override: T2 | undefined -): T1 => { +const mergePartiallyShallow = (seed: T1, override: Partial | undefined): T1 => { + if (!seed) { + throw new TypeError(`seed must be provided and must not be null/undefined. It was ${typeof seed}`); + } + // TODO: we can stop relying on runtime type checks if/when negated types are introduced: https://github.com/microsoft/TypeScript/pull/29317#issuecomment-692750988 + if (Array.isArray(seed)) { + throw new TypeError('this function only supports non-array objects.'); + } const seedCopy = deepClone(seed); - if (typeof override === 'undefined') { + // Short-circuit if override was not provided + if (!override) { return seedCopy; } // Lie #1 - the object and it's override are objects with iterable keys. More information here: https://github.com/microsoft/TypeScript/issues/35859#issuecomment-687323281 const seedRecord = seedCopy as ARecordOfAllPossible; - const overrideRecord = override as ARecordOfAllPossible; + const overrideRecord = (override as unknown) as ARecordOfAllPossible; const overrideKeyValuePairs = toKVP(overrideRecord); @@ -99,3 +116,20 @@ export const mergePartially = >( return seedCopy; }; + +/** + * Returns a copy of seed with any non-undefined parameters from override + * @param seed the object that is used establish the start of what you want the result to look like. This is the object that will be overriden before a result is produced + * @param override the data that will be used when replacing the seed's key/values + */ +const mergePartiallyDeep = | undefined = undefined>( + seed: T1, + override: T2 +): NoNestedOptionalObjectsDeep => { + return mergePartiallyShallow(seed, override) as NoNestedOptionalObjectsDeep; +}; + +export const mergePartially = { + shallow: mergePartiallyShallow, + deep: mergePartiallyDeep, +}; diff --git a/whyShallowInstead.md b/whyShallowInstead.md new file mode 100644 index 0000000..b14c148 --- /dev/null +++ b/whyShallowInstead.md @@ -0,0 +1,168 @@ +# Why the deep version of the library is not working for you + +Let's dive into why you are getting an error saying that the data type you submitted is not supported. + +In the following example, what would you expect `result.preferences.lastUpdated` to be? + +```ts +interface IDeepObj { + userName: string; + preferences?: { + lastUpdated: Date; + favoriteColor?: string; + backupContact?: string; + }; +} + +const seed: IDeepObj = { + userName: 'Bob Smith', +}; + +const result = mergePartially.deep(seed, { + preferences: { + favoriteColor: 'blue', + }, +}); +``` + +The correct answer is that `result.preferences.lastUpdated` is `undefined` at runtime since it was not supplied by `seed` or by the override. Unfortunately, the TypeScript compiler thinks that `result.preferences.lastUpdated` is `Date` due to the way that the Partial type works. This is a problem. The runtime type and the compile-time type should be the same. + +So to ensure that the mergePartially library meets it's design goals of preventing the compile-time type from being incorrect, we've guarded from this case by defensively returning a type that clarifies the problem. + +## Solutions + +There are multiple solutions depending on your circumstances. + +## What you think might solve it, but won't actually solve the problem + +Q: Why don't I just make sure that the `seed` object has the optional object? +A: Because Typescript only knows the interface type, not the type that arrives at runtime. So mergePartially will still think that the optional object might not be there at runtime. + +## Solve by using mergePartially.shallow (START WITH THIS SOLUTION): + +This is likely the best first option since it will make it clear to you what the problem is. By using mergePartially.shallow you can ensure that each level of the nested object is fully populated by the seed and/or the override. + +```ts +const result = mergePartially.shallow(seed, { + preferences: { + lastUpdated: new Date(), // <-- Yay, Typescript required us to pass lastUpdated. mergePartially.deep did not, but it still protects from this scenario. + favoriteColor: 'blue', + }); +``` + +What this means is "if you pass a `preferences` object, you are going to have to pass all of it (because it's a nested object)." And maybe the developers find this to be acceptable. + +However, if you still have a scenario where you only want to provide a part of the nested object, continue below to one of the other solutions + +## Solve by changing the type for the seed to not have optional objects: + +This solution is not always available, but it will certainly make your code simpler. This might be harder to do if the type comes from an API; however, if you're using mergePartially in test code for the purposes of factory, it's perfectly reasonable to have the test code that provides a safer data type than what the API responds with. Also, just a random suggestion here, but please ask your API developer if the optional object can be made required-- you might find that they already do gaurantee (via their own API test) that it is sent and that your interface is just out of date. + +So if you were able to make the following change, you can continue to `use mergePartially.deep`. If not, skip below to the next potential solution. + +```ts +// Replace this... +interface IDeepObj { + userName: string; + preferences?: { + lastUpdated: Date; + favoriteColor?: string; + backupContact?: string; + }; +} +// ...with a interface where preferences is required +interface IDeepObj { + userName: string; + preferences: { + // i.e. we deleted the question mark from preferences + lastUpdated: Date; + favoriteColor?: string; + backupContact?: string; + }; +} +``` + +Why is this acceptable to mergePartially? Because the seed requires you to pass in preferences which means you don't end up with the original problem where `result.preferences.lastUpdated` was `undefined` at runtime. + +See here: + +```ts +interface IDeepObj { + userName: string; + preferences: { + lastUpdated: Date; + favoriteColor?: string; + backupContact?: string; + }; +} + +const seed: IDeepObj = { + userName: 'Bob Smith', + preferences: { + // The preferences object is required now, and therefore the preferences.lastUpdated property is also required thus ensuring that result is the same type as the seed + lastUpdated: new Date(), + }, +}; + +const result = mergePartially.deep(seed, { + preferences: { + favoriteColor: 'blue', + }, +}); +``` + +## Solve by explicitly requiring each nested object + +This solution is the most verbose, but it is the most explicit about what is required. The best time to use this solution is if you are in test code and you want to make sure that unit tests are clear about what data is being set up. + +So with that in mind, the following example will utilize factory functions to show how multiple unit tests might rely on the same factory function. + +```ts +// Arrange +const mockAddressFactory = (override: Partial): IAddress => { + // If no override is provided, this totally random object will be returned. + // The randomness ensures that tests are not tightly coupled to specific data + // ...unless they want to have specific data... in which, case they have to provide that data upon calling the factory funtion + const addressSeed: IAddress = { + street: faker.address.streetAddress(), + city: faker.address.city(), + state: faker.address.stateAbbr(), + zipCode: faker.address.zipCode(), + }; + return mergePartially.shallow(addressSeed, override); +}; + +const mockPreferencesFactory = (override: Partial): IDeepObj['preferences'] => { + const preferncesSeed: IDeepObj['preferences'] = { + lastUpdated: faker.date.future(), + backupContact: faker.phone.phoneNumber(), + favoriteColor: faker.random.word(), + }; + return mergePartially.shallow(preferncesSeed, override); +}; + +const mockUserFactory = (override: Partial): IDeepObj => { + const userSeed: IDeepObj = { + userName: faker.name.firstName() + ' ' + faker.name.lastName(), + }; + return mergePartially.shallow(userSeed, override); +}; + +const resultOfWorkaround = mockUserFactory({ + preferences: mockPreferencesFactory({ + mailingAddress: mockAddressFactory({ + state: 'NY', + }), + }), +}); + +// Assert +// This would fail and state would be undefined if our factory functions were improperly set up, or or if mergePartially was misbehaving +expect(resultOfWorkaround.preferences?.mailingAddress?.state).toEqual('NY'); +``` + +Notice that you could just as easily called `mockUserFactory` again if you wanted to arrange for a test to have a different state abbreviation. That's the power of factory functions + mergePartially. + +## Other solutions + +It's our hope that most people won't run into this nested optional object problem. However, if you have encountered this error and the solutions above do not work for you, please create a Github issue describing your scenario and your desired results. We thrive on feedback and look forward to finding the ideal solution. That being said, most users have reported that the above solutions resolve and in some cases even improve their code.