Repository: dubzzz/fast-check
Pull request #1917 (2021-05-27 20:13:39)

⚡️ Remove unneeded checks in `map` for context-less `shrink`
<!-- Context of the PR: short description and potentially linked issues -->

Revert: https://github.com/dubzzz/fast-check/pull/1822

It was a hack hiding bad usages of the arbitraries.

<!-- ...a few words to describe the content of this PR...               -->
<!-- ... -->

<!-- Type of PR: [ ] unchecked / [ ] checked -->
**_Category:_**

- [ ] ✨ Introduce new features
- [ ] 📝 Add or update documentation
- [ ] ✅ Add or update tests
- [ ] 🐛 Fix a bug
- [ ] 🏷️ Add or update types
- [x] _Other(s):_ ...
<!-- Don't forget to add the gitmoji icon in the name of the PR -->
<!-- See: https://gitmoji.dev/                                  -->

<!-- Fixing bugs, adding feastures... may impact existing ones           -->
<!-- in order to track potential issues that could be related to your PR -->
<!-- please check the impacts and describe more precisely what to expect -->

**_Potential impacts:_**

<!-- Generated values: Can your change impact any of the existing generators in terms of generated values, if so which ones? when? -->
<!-- Shrink values:    Can your change impact any of the existing generators in terms of shrink values, if so which ones? when? -->
<!-- Performance:      Can it require some typings changes on user side? Please give more details -->
<!-- Typings:          Is there a potential performance impact? In which cases? -->
- [ ] Generated values
- [ ] Shrink values
- [x] Performance
- [ ] Typings
- [ ] _Other(s):_ ...


codesandbox[bot]: This pull request is automatically built and testable in [CodeSandbox](https://codesandbox.io).

  To see build info of the built libraries, click [here](https://ci.codesandbox.io/status/dubzzz/fast-check/pr/1917/builds/134502) or the icon next to each commit SHA.

Latest deployment of this branch, based on commit d7e0cf4ea6fda34c084753ef0d2f368e4eb7d7f3:

|Sandbox| Source |
|--|--|
|[Vanilla](https://codesandbox.io/s/vanilla-to3i7)| Configuration |
|[fast-check-examples](https://codesandbox.io/s/fast-check-examples-z7t20)| Configuration |

codecov[bot]: # [Codecov](https://codecov.io/gh/dubzzz/fast-check/pull/1917?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN) Report
> Merging [#1917](https://codecov.io/gh/dubzzz/fast-check/pull/1917?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN) (60b3f4f) into [main](https://codecov.io/gh/dubzzz/fast-check/commit/09ce57c54317140a5b984b1486f94f016c136493?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN) (09ce57c) will **increase** coverage by `0.00%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/dubzzz/fast-check/pull/1917/graphs/tree.svg?width=650&height=150&src=pr&token=fRi2fJN730&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN)](https://codecov.io/gh/dubzzz/fast-check/pull/1917?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN)

```diff
@@           Coverage Diff           @@
##             main    #1917   +/-   ##
=======================================
  Coverage   97.49%   97.49%           
=======================================
  Files         165      165           
  Lines        4073     4079    +6     
  Branches      789      789           
=======================================
+ Hits         3971     3977    +6     
  Misses         98       98           
  Partials        4        4           
```

| Flag | Coverage Δ | |
|---|---|---|
| unit-tests | `97.49% <100.00%> (+<0.01%)` | :arrow_up: |
| unit-tests-10.x-Linux | `?` | |
| unit-tests-12.x-Linux | `?` | |
| unit-tests-14.x-Linux | `97.49% <100.00%> (+<0.01%)` | :arrow_up: |
| unit-tests-16.x-Linux | `97.49% <100.00%> (+<0.01%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/dubzzz/fast-check/pull/1917?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN) | Coverage Δ | |
|---|---|---|
| [src/check/arbitrary/definition/NextArbitrary.ts](https://codecov.io/gh/dubzzz/fast-check/pull/1917/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN#diff-c3JjL2NoZWNrL2FyYml0cmFyeS9kZWZpbml0aW9uL05leHRBcmJpdHJhcnkudHM=) | `96.87% <100.00%> (-0.03%)` | :arrow_down: |
| [src/arbitrary/option.ts](https://codecov.io/gh/dubzzz/fast-check/pull/1917/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN#diff-c3JjL2FyYml0cmFyeS9vcHRpb24udHM=) | `100.00% <0.00%> (ø)` | |
| [src/arbitrary/\_internals/FrequencyArbitrary.ts](https://codecov.io/gh/dubzzz/fast-check/pull/1917/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN#diff-c3JjL2FyYml0cmFyeS9faW50ZXJuYWxzL0ZyZXF1ZW5jeUFyYml0cmFyeS50cw==) | `98.07% <0.00%> (+0.11%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/dubzzz/fast-check/pull/1917?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/dubzzz/fast-check/pull/1917?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN). Last update [09ce57c...60b3f4f](https://codecov.io/gh/dubzzz/fast-check/pull/1917?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN).


Patch:
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index a1b74389..2d5d6523 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -15,7 +15,7 @@
 <!-- Don't forget to add the gitmoji icon in the name of the PR -->
 <!-- See: https://gitmoji.dev/                                  -->
 
-<!-- Fixing bugs, adding feastures... may impact existing ones           -->
+<!-- Fixing bugs, adding features... may impact existing ones           -->
 <!-- in order to track potential issues that could be related to your PR -->
 <!-- please check the impacts and describe more precisely what to expect -->
 
diff --git a/documentation/AdvancedArbitraries.md b/documentation/AdvancedArbitraries.md
index 62d428c4..dfaa006c 100644
--- a/documentation/AdvancedArbitraries.md
+++ b/documentation/AdvancedArbitraries.md
@@ -14,6 +14,8 @@ Please do not hesitate to open issues to ask for new arbitraries.
   - [Transform arbitraries](#transform-arbitraries)
   - [Remove the shrinker](#remove-the-shrinker)
 - [Build your own](#build-your-own)
+  - [Starting at version 2.15.0](#starting-at-version-2.15.0)
+  - [Before version 2.15.0](#before-version-2.15.0)
 - [Advanced features of arbitraries](#advanced-features-of-arbitraries)
   - [Biased arbitraries](#biased-arbitraries)
   - [Shrinking](#shrinking)
@@ -90,20 +92,32 @@ const intNoShrink = fc.integer().noShrink();
 
 ## Build your own
 
-**NOTE:** Before writing your own arbitrary from scratch you should have a look to the [examples](https://github.com/dubzzz/fast-check/tree/main/example) provided in the repository. There are examples for: [recursive structures](https://github.com/dubzzz/fast-check/tree/main/example/002-recursive/isSearchTree), [properties for automata or state machine](https://github.com/dubzzz/fast-check/tree/main/example/004-stateMachine/musicPlayer) and others.
+In general, whatever the version of fast-check you are using, it is highly recommended to have a look to how [built-in arbitraries](https://github.com/dubzzz/fast-check/tree/main/src/arbitrary) have been implementated and to the simpler [examples](https://github.com/dubzzz/fast-check/tree/main/example) provided in the repository.
 
-You can also fully customize your arbitrary: not derive it from any of the buit-in arbitraries. What you have to do is to extend [Arbitrary](https://github.com/dubzzz/fast-check/blob/main/src/check/arbitrary/definition/Arbitrary.ts) and implement `generate(mrng: Random): Shrinkable<T>`.
+### Starting at version 2.15.0
 
-`generate` is responsible for the generation of one new random entity of type `T` (see signature above). In order to fulfill it in a deterministic way it receives a `mrng: Random`:
-- `next(n: number): number`: uniformly distributed n bits value (max value of n = 31)
-- `nextBoolean(): boolean`: uniformly distributed boolean value
-- `nextInt(): number`: uniformly distributed integer value
-- `nextInt(from: number, to: number): number`: uniformly distributed integer value between from (inclusive) and to (inclusive)
-- `nextDouble(): number`: uniformly distributed double value between 0.0 (included) and 1.0 (not included)
+**Your version is 2.15.0 or above**
 
-The generated value is responsible for its shrinking. Shrinking derives the item into _smaller_ values and is optional.
+In such case, even if extending the [class `Arbitrary`](https://github.com/dubzzz/fast-check/blob/99e9b3b1c918a627d92138fc7e00f190ea99fecb/src/check/arbitrary/definition/Arbitrary.ts#L13) still works fine, it is highly recommended to extend the [class `NextArbitrary`](https://github.com/dubzzz/fast-check/blob/99e9b3b1c918a627d92138fc7e00f190ea99fecb/src/check/arbitrary/definition/NextArbitrary.ts#L12).
 
-Refer to [built-in types](https://github.com/dubzzz/fast-check/tree/main/src/check/arbitrary) for examples of fully custom arbitraries.
+An instance of `NextArbitrary` must define three methods:
+- `generate(mrng: Random, biasFactor: number | undefined): NextValue<T>`: Given a random generator and possibly a bias (≥2), it must generate a single value along with its context (if applicable). The context is an opaque value that should only be accessed by the class that produced it. This opaque value can be helpful to guide the shrinker and give it more context on the value, how it has been produced...
+- `shrink(value: T, context: unknown | undefined): Stream<NextValue<T>>`: Given a value and possibly a context (produced by `generate` or `shrink` of the very same instance), it has to produce a Stream of smaller values. Please note that the function always has to be called with a context except if `canShrinkWithoutContext` tells the caller that it can be called context-less for this precise value.
+
+- `canShrinkWithoutContext(value: unknown): value is T`: Given a value it can tells the caller whether or not `shrink` can be called on it without passing a context. If the returned value is `false` then it means that this value should not be passed to `shrink` without its context.
+
+But version 2.x of fast-check does not deal with instances of `NextArbitrary` from an API point-of-view so you need to convert them towards old instances using the helper `convertFromNext`. You can also convert old instances to new ones uisng `convertToNext`.
+
+Since 2.15.0, most of the built-ins arbitraries coming with fast-check are based `NextArbitrary` hidden by a `convertFromNext`.
+
+### Before version 2.15.0
+
+**Your version is strictly older than 2.15.0**
+
+In such case, you have to extend the [class `Arbitrary`](https://github.com/dubzzz/fast-check/blob/99e9b3b1c918a627d92138fc7e00f190ea99fecb/src/check/arbitrary/definition/Arbitrary.ts#L13).
+
+It consists in a single method: `generate(mrng: Random): Shrinkable<T>`.
+It takes a random number generator and it generates a value and the whole shrinking process to shrink it.
 
 ## Advanced features of arbitraries
 
diff --git a/src/arbitrary/_internals/builders/PartialRecordArbitraryBuilder.ts b/src/arbitrary/_internals/builders/PartialRecordArbitraryBuilder.ts
index 2b6628aa..e86091ce 100644
--- a/src/arbitrary/_internals/builders/PartialRecordArbitraryBuilder.ts
+++ b/src/arbitrary/_internals/builders/PartialRecordArbitraryBuilder.ts
@@ -1,7 +1,12 @@
 import { Arbitrary } from '../../../check/arbitrary/definition/Arbitrary';
+import { convertFromNext, convertToNext } from '../../../check/arbitrary/definition/Converters';
 import { option } from '../../option';
 import { tuple } from '../../tuple';
 import { EnumerableKeyOf, extractEnumerableKeys } from '../helpers/EnumerableKeysExtractor';
+import {
+  buildValuesAndSeparateKeysToObjectMapper,
+  buildValuesAndSeparateKeysToObjectUnmapper,
+} from '../mappers/ValuesAndSeparateKeysToObject';
 
 const noKeyValue: unique symbol = Symbol('no-key');
 type NoKeyType = typeof noKeyValue;
@@ -19,14 +24,10 @@ export function buildPartialRecordArbitrary<T, TKeys extends EnumerableKeyOf<T>>
     if (requiredKeys === undefined || requiredKeys.indexOf(k as TKeys) !== -1) arbs.push(requiredArbitrary);
     else arbs.push(option(requiredArbitrary, { nil: noKeyValue }));
   }
-  return tuple(...arbs).map((gs: (T[keyof T] | NoKeyType)[]): Partial<T> & Pick<T, TKeys> => {
-    const obj: Partial<Record<EnumerableKeyOf<T>, T[keyof T]>> = {};
-    for (let idx = 0; idx !== keys.length; ++idx) {
-      const valueWrapper = gs[idx];
-      if (valueWrapper !== noKeyValue) {
-        obj[keys[idx]] = valueWrapper;
-      }
-    }
-    return obj as Partial<T> & Pick<T, TKeys>;
-  });
+  return convertFromNext(
+    convertToNext(tuple(...arbs)).map(
+      buildValuesAndSeparateKeysToObjectMapper<T, NoKeyType>(keys, noKeyValue),
+      buildValuesAndSeparateKeysToObjectUnmapper<T, NoKeyType>(keys, noKeyValue)
+    )
+  );
 }
diff --git a/src/arbitrary/_internals/mappers/ValuesAndSeparateKeysToObject.ts b/src/arbitrary/_internals/mappers/ValuesAndSeparateKeysToObject.ts
new file mode 100644
index 00000000..2326094c
--- /dev/null
+++ b/src/arbitrary/_internals/mappers/ValuesAndSeparateKeysToObject.ts
@@ -0,0 +1,52 @@
+import { EnumerableKeyOf } from '../helpers/EnumerableKeysExtractor';
+
+/** @internal */
+export function buildValuesAndSeparateKeysToObjectMapper<T, TNoKey>(keys: EnumerableKeyOf<T>[], noKeyValue: TNoKey) {
+  return function valuesAndSeparateKeysToObjectMapper(
+    gs: (T[keyof T] | TNoKey)[]
+  ): Partial<T> & Pick<T, EnumerableKeyOf<T>> {
+    const obj: Partial<Record<EnumerableKeyOf<T>, T[keyof T]>> = {};
+    for (let idx = 0; idx !== keys.length; ++idx) {
+      const valueWrapper = gs[idx];
+      if (valueWrapper !== noKeyValue) {
+        obj[keys[idx]] = valueWrapper as T[keyof T]; // not TNoKey
+      }
+    }
+    return obj as Partial<T> & Pick<T, EnumerableKeyOf<T>>;
+  };
+}
+
+/** @internal */
+export function buildValuesAndSeparateKeysToObjectUnmapper<T, TNoKey>(keys: EnumerableKeyOf<T>[], noKeyValue: TNoKey) {
+  return function valuesAndSeparateKeysToObjectUnmapper(value: unknown): (T[keyof T] | TNoKey)[] {
+    if (typeof value !== 'object' || value === null) {
+      throw new Error('Incompatible instance received: should be a non-null object');
+    }
+    if (!('constructor' in value) || value.constructor !== Object) {
+      throw new Error('Incompatible instance received: should be of exact type Object');
+    }
+    let extractedPropertiesCount = 0;
+    const extractedValues: (T[keyof T] | TNoKey)[] = [];
+    for (let idx = 0; idx !== keys.length; ++idx) {
+      const descriptor = Object.getOwnPropertyDescriptor(value, keys[idx]);
+      if (descriptor !== undefined) {
+        if (!descriptor.configurable || !descriptor.enumerable || !descriptor.writable) {
+          throw new Error('Incompatible instance received: should contain only c/e/w properties');
+        }
+        if (descriptor.get !== undefined || descriptor.set !== undefined) {
+          throw new Error('Incompatible instance received: should contain only no get/set properties');
+        }
+        ++extractedPropertiesCount;
+        extractedValues.push(descriptor.value);
+      } else {
+        extractedValues.push(noKeyValue);
+      }
+    }
+    const namePropertiesCount = Object.getOwnPropertyNames(value).length;
+    const symbolPropertiesCount = Object.getOwnPropertySymbols(value).length;
+    if (extractedPropertiesCount !== namePropertiesCount + symbolPropertiesCount) {
+      throw new Error('Incompatible instance received: should not contain extra properties');
+    }
+    return extractedValues;
+  };
+}
diff --git a/src/check/arbitrary/definition/NextArbitrary.ts b/src/check/arbitrary/definition/NextArbitrary.ts
index fe05db9f..36cbd63a 100644
--- a/src/check/arbitrary/definition/NextArbitrary.ts
+++ b/src/check/arbitrary/definition/NextArbitrary.ts
@@ -288,20 +288,11 @@ class MapArbitrary<T, U> extends NextArbitrary<U> {
       return this.arb.shrink(context.originalValue, context.originalContext).map(this.bindValueMapper);
     }
     if (this.unmapper !== undefined) {
-      // As `shrink` might be called with correct values where the unmapper has sevaral choices even
-      // entries looking correct could be invalid. If the user has not called `canShrinkWithoutContext` first, we
-      // might easily fall in such cases.
-      //
-      // Let's imagine: `mapper = (arr: string[]) => arr.join(" ")`
-      // >  mapper(["Hello", "how are", "you?"]) = "Hello how are you?"
-      // Unfortunately our unmapper might not unmap it correctly as several inputs give the same output.
-      // In order to be able to execute `shrink` safely on the unmapped value, we also need to check the
-      // unmapped value is correct for the source arbitrary. In the example above, we might imagine that
-      // our source arbitrary can shrink ["Hello", "how are", "you?"] but not ["Hello", "how", "are", "you?"].
       const unmapped = this.unmapper(value);
-      if (this.arb.canShrinkWithoutContext(unmapped)) {
-        return this.arb.shrink(unmapped).map(this.bindValueMapper);
-      }
+      // As `shrink` should never be called without a valid context
+      // except if `canGenerate` tells that the value was compatible with a shrink without any context
+      // we can safely consider `this.arb.canGenerate(unmapped)` to be true at that point.
+      return this.arb.shrink(unmapped).map(this.bindValueMapper);
     }
     return Stream.nil();
   }


Test patch:
diff --git a/test/unit/arbitrary/RecordArbitrary.spec.ts b/test/unit/arbitrary/RecordArbitrary.spec.ts
deleted file mode 100644
index 9b313231..00000000
--- a/test/unit/arbitrary/RecordArbitrary.spec.ts
+++ /dev/null
@@ -1,180 +0,0 @@
-import * as prand from 'pure-rand';
-import * as fc from '../../../lib/fast-check';
-
-import { constant } from '../../../src/arbitrary/constant';
-import { Arbitrary } from '../../../src/check/arbitrary/definition/Arbitrary';
-import { integer } from '../../../src/arbitrary/integer';
-import { record, RecordConstraints } from '../../../src/arbitrary/record';
-import { Random } from '../../../src/random/generator/Random';
-
-import * as genericHelper from '../check/arbitrary/generic/GenericArbitraryHelper';
-
-const keyArb: fc.Arbitrary<any> = fc
-  .tuple(fc.string(), fc.boolean())
-  .map(([name, symbol]) => (symbol ? Symbol.for(name) : name));
-
-describe('RecordArbitrary', () => {
-  describe('record', () => {
-    it('Should produce a record with missing keys', () =>
-      fc.assert(
-        fc.property(fc.set(keyArb, { minLength: 1 }), fc.nat(), fc.integer(), (keys, missingIdx, seed) => {
-          const mrng = new Random(prand.xorshift128plus(seed));
-          const recordModel: Record<string | symbol, Arbitrary<any>> = {};
-          for (const k of keys) recordModel[k] = constant(k);
-
-          const arb = record(recordModel, { withDeletedKeys: true });
-          for (let idx = 0; idx != 1000; ++idx) {
-            const g = arb.generate(mrng).value;
-            if (!Object.prototype.hasOwnProperty.call(g, keys[missingIdx % keys.length])) return true;
-          }
-          return false;
-        })
-      ));
-    it('Should produce a record with present keys', () =>
-      fc.assert(
-        fc.property(fc.set(keyArb, { minLength: 1 }), fc.nat(), fc.integer(), (keys, missingIdx, seed) => {
-          const mrng = new Random(prand.xorshift128plus(seed));
-          const recordModel: Record<string | symbol, Arbitrary<any>> = {};
-          for (const k of keys) {
-            recordModel[k] = constant(k);
-          }
-
-          const arb = record(recordModel, { withDeletedKeys: true });
-          for (let idx = 0; idx != 1000; ++idx) {
-            const g = arb.generate(mrng).value;
-            if (g[keys[missingIdx % keys.length]] === keys[missingIdx % keys.length]) return true;
-          }
-          return false;
-        })
-      ));
-    it('Should reject configurations specifying non existing keys as required', () =>
-      fc.assert(
-        fc.property(fc.set(keyArb, { minLength: 1 }), keyArb, (keys, requiredKey) => {
-          fc.pre(!keys.includes(requiredKey));
-
-          const recordModel: Record<string | symbol, Arbitrary<any>> = {};
-          for (const k of keys) {
-            recordModel[k] = constant(k);
-          }
-
-          expect(() =>
-            record(recordModel, {
-              requiredKeys: [requiredKey],
-            })
-          ).toThrowError();
-        })
-      ));
-    it('Should reject configurations specifying both requiredKeys and withDeletedKeys (even undefined)', () =>
-      fc.assert(
-        fc.property(
-          fc.set(fc.record({ name: keyArb, required: fc.boolean() }), {
-            minLength: 1,
-            compare: (a, b) => a.name === b.name,
-          }),
-          fc.option(fc.constant(true), { nil: undefined }),
-          fc.option(fc.boolean(), { nil: undefined }),
-          (keys, withRequiredKeys, withDeletedKeys) => {
-            const recordModel: Record<string | symbol, Arbitrary<any>> = {};
-            for (const k of keys) {
-              recordModel[k.name] = constant(k.name);
-            }
-
-            expect(() =>
-              record(recordModel, {
-                requiredKeys: withRequiredKeys ? keys.filter((k) => k.required).map((k) => k.name) : undefined,
-                withDeletedKeys: withDeletedKeys,
-              })
-            ).toThrowError();
-          }
-        )
-      ));
-    it('Should accept empty keys configurations with empty requiredKeys', () => {
-      const recordModel: Record<string | symbol, Arbitrary<any>> = {};
-      expect(() => record(recordModel, { requiredKeys: [] })).not.toThrowError();
-    });
-    it.each`
-      keyName
-      ${'constructor'}
-      ${'toString'}
-      ${'__proto__'}
-    `('Should reject configurations specifying non own properties ($keyName) as requiredKeys', ({ keyName }) => {
-      const recordModel: Record<string | symbol, Arbitrary<any>> = {};
-      expect(() => record(recordModel, { requiredKeys: [keyName] })).toThrowError();
-    });
-    it('Should reject configurations specifying non enumerable properties as requiredKeys', () => {
-      const keyName = 'k';
-      const recordModel: Record<string | symbol, Arbitrary<any>> = {};
-      Object.defineProperty(recordModel, keyName, { value: fc.boolean(), enumerable: false });
-      expect(() => record(recordModel, { requiredKeys: [keyName] })).toThrowError();
-    });
-
-    type Meta = { key: any; valueStart: number; kept: boolean };
-    const metaArbitrary = fc.set(
-      fc.record({
-        key: keyArb,
-        valueStart: fc.nat(1000),
-        kept: fc.boolean(),
-      }),
-      { compare: (v1, v2) => v1.key === v2.key }
-    );
-    const constraintsArbitrary = fc.oneof(
-      fc.record({ withDeletedKeys: fc.boolean() }, { requiredKeys: [] }),
-      fc.record({ withRequiredKeys: fc.constant<true>(true) }, { requiredKeys: [] })
-    );
-
-    describe('Given a custom record configuration', () => {
-      genericHelper.isValidArbitrary(
-        ([metas, constraints]: [Meta[], RecordConstraints<any>]) => {
-          const recordModel: Record<string | symbol, Arbitrary<number>> = {};
-          for (const m of metas) {
-            recordModel[m.key] = integer(m.valueStart, m.valueStart + 10);
-          }
-          return record(recordModel, constraints);
-        },
-        {
-          seedGenerator: fc
-            .tuple(metaArbitrary, constraintsArbitrary)
-            .map(([metas, constraintsMeta]: [Meta[], { withDeletedKeys?: boolean; withRequiredKeys?: true }]) => {
-              if ('withRequiredKeys' in constraintsMeta) {
-                return [metas, { requiredKeys: metas.filter((m) => m.kept).map((m) => m.key) }] as [
-                  Meta[],
-                  RecordConstraints
-                ];
-              }
-              return [metas, constraintsMeta] as [Meta[], RecordConstraints];
-            }),
-          isValidValue: (r: { [key: string]: number }, [metas, constraints]: [Meta[], RecordConstraints]) => {
-            // Rq: getOwnPropertyNames will also get non enumerable properties, but there are none in our case
-            for (const k of [...Object.getOwnPropertyNames(r), ...Object.getOwnPropertySymbols(r)]) {
-              // generated object should not have more keys
-              if (metas.findIndex((m) => m.key === k) === -1) return false;
-            }
-            for (const m of metas) {
-              // optional keys can be missing in the generated instance
-              if (
-                'withDeletedKeys' in constraints &&
-                constraints.withDeletedKeys === true &&
-                !Object.prototype.hasOwnProperty.call(r, m.key)
-              ) {
-                continue;
-              }
-              if (
-                'requiredKeys' in constraints &&
-                constraints.requiredKeys !== undefined &&
-                !constraints.requiredKeys.includes(m.key) &&
-                !Object.prototype.hasOwnProperty.call(r, m.key)
-              ) {
-                continue;
-              }
-              // values are associated to the right key (if key required)
-              if (typeof r[m.key] !== 'number') return false;
-              if (r[m.key] < m.valueStart) return false;
-              if (r[m.key] > m.valueStart + 10) return false;
-            }
-            return true;
-          },
-        }
-      );
-    });
-  });
-});
diff --git a/test/unit/arbitrary/_internals/builders/PartialRecordArbitraryBuilder.spec.ts b/test/unit/arbitrary/_internals/builders/PartialRecordArbitraryBuilder.spec.ts
new file mode 100644
index 00000000..04a82895
--- /dev/null
+++ b/test/unit/arbitrary/_internals/builders/PartialRecordArbitraryBuilder.spec.ts
@@ -0,0 +1,168 @@
+import { fakeNextArbitrary } from '../../../check/arbitrary/generic/NextArbitraryHelpers';
+import { convertFromNext, convertToNext } from '../../../../../src/check/arbitrary/definition/Converters';
+import { buildPartialRecordArbitrary } from '../../../../../src/arbitrary/_internals/builders/PartialRecordArbitraryBuilder';
+
+import * as OptionMock from '../../../../../src/arbitrary/option';
+import * as TupleMock from '../../../../../src/arbitrary/tuple';
+import * as ValuesAndSeparateKeysToObjectMock from '../../../../../src/arbitrary/_internals/mappers/ValuesAndSeparateKeysToObject';
+
+function beforeEachHook() {
+  jest.resetModules();
+  jest.restoreAllMocks();
+}
+beforeEach(beforeEachHook);
+
+describe('buildPartialRecordArbitrary', () => {
+  it('should never wrap arbitraries linked to required keys and forward all keys to mappers', () => {
+    // Arrange
+    const { instance: mappedInstance } = fakeNextArbitrary<any>();
+    const { instance: tupleInstance, map } = fakeNextArbitrary<any[]>();
+    const option = jest.spyOn(OptionMock, 'option');
+    const tuple = jest.spyOn(TupleMock, 'tuple');
+    tuple.mockReturnValue(convertFromNext(tupleInstance));
+    map.mockReturnValue(mappedInstance);
+
+    const mapper = jest.fn();
+    const buildValuesAndSeparateKeysToObjectMapper = jest.spyOn(
+      ValuesAndSeparateKeysToObjectMock,
+      'buildValuesAndSeparateKeysToObjectMapper'
+    );
+    buildValuesAndSeparateKeysToObjectMapper.mockReturnValue(mapper);
+
+    const unmapper = jest.fn();
+    const buildValuesAndSeparateKeysToObjectUnmapper = jest.spyOn(
+      ValuesAndSeparateKeysToObjectMock,
+      'buildValuesAndSeparateKeysToObjectUnmapper'
+    );
+    buildValuesAndSeparateKeysToObjectUnmapper.mockReturnValue(unmapper);
+
+    const arbKey1 = fakeNextArbitrary();
+    const arbKey2 = fakeNextArbitrary();
+    const recordModel = {
+      a: convertFromNext(arbKey1),
+      b: convertFromNext(arbKey2),
+    };
+    const requiredKeys: (keyof typeof recordModel)[] = ['a', 'b'];
+    const allKeys: (keyof typeof recordModel)[] = ['a', 'b'];
+
+    // Act
+    const arb = buildPartialRecordArbitrary(recordModel, requiredKeys);
+
+    // Assert
+    expect(convertToNext(arb)).toBe(mappedInstance);
+    expect(option).not.toHaveBeenCalled();
+    expect(tuple).toHaveBeenCalledTimes(1);
+    expect(tuple).toHaveBeenCalledWith(recordModel.a, recordModel.b);
+    expect(buildValuesAndSeparateKeysToObjectMapper).toHaveBeenCalledTimes(1);
+    expect(buildValuesAndSeparateKeysToObjectMapper).toHaveBeenCalledWith(allKeys, expect.any(Symbol));
+    expect(buildValuesAndSeparateKeysToObjectUnmapper).toHaveBeenCalledTimes(1);
+    expect(buildValuesAndSeparateKeysToObjectUnmapper).toHaveBeenCalledWith(allKeys, expect.any(Symbol));
+    expect(map).toHaveBeenCalledTimes(1);
+    expect(map).toHaveBeenCalledWith(mapper, unmapper);
+  });
+
+  it('should wrap arbitraries not linked to required keys into option and forward all keys to mappers', () => {
+    // Arrange
+    const { instance: mappedInstance } = fakeNextArbitrary<any>();
+    const { instance: tupleInstance, map } = fakeNextArbitrary<any[]>();
+    const { instance: optionInstance1 } = fakeNextArbitrary();
+    const { instance: optionInstance2 } = fakeNextArbitrary();
+    const option = jest.spyOn(OptionMock, 'option');
+    const tuple = jest.spyOn(TupleMock, 'tuple');
+    const optionInstance1Old = convertFromNext(optionInstance1);
+    const optionInstance2Old = convertFromNext(optionInstance2);
+    option.mockReturnValueOnce(optionInstance1Old).mockReturnValueOnce(optionInstance2Old);
+    tuple.mockReturnValue(convertFromNext(tupleInstance));
+    map.mockReturnValue(mappedInstance);
+
+    const mapper = jest.fn();
+    const buildValuesAndSeparateKeysToObjectMapper = jest.spyOn(
+      ValuesAndSeparateKeysToObjectMock,
+      'buildValuesAndSeparateKeysToObjectMapper'
+    );
+    buildValuesAndSeparateKeysToObjectMapper.mockReturnValue(mapper);
+
+    const unmapper = jest.fn();
+    const buildValuesAndSeparateKeysToObjectUnmapper = jest.spyOn(
+      ValuesAndSeparateKeysToObjectMock,
+      'buildValuesAndSeparateKeysToObjectUnmapper'
+    );
+    buildValuesAndSeparateKeysToObjectUnmapper.mockReturnValue(unmapper);
+
+    const arbKey1 = fakeNextArbitrary();
+    const arbKey2 = fakeNextArbitrary();
+    const arbKey3 = fakeNextArbitrary();
+    const recordModel = {
+      a: convertFromNext(arbKey1),
+      b: convertFromNext(arbKey2),
+      c: convertFromNext(arbKey3),
+    };
+    const requiredKeys: (keyof typeof recordModel)[] = ['b'];
+    const allKeys: (keyof typeof recordModel)[] = ['a', 'b', 'c'];
+
+    // Act
+    const arb = buildPartialRecordArbitrary(recordModel, requiredKeys);
+
+    // Assert
+    expect(convertToNext(arb)).toBe(mappedInstance);
+    expect(option).toHaveBeenCalledTimes(2);
+    expect(option).toHaveBeenCalledWith(recordModel.a, { nil: expect.any(Symbol) });
+    expect(option).toHaveBeenCalledWith(recordModel.c, { nil: expect.any(Symbol) });
+    expect(tuple).toHaveBeenCalledTimes(1);
+    expect(tuple).toHaveBeenCalledWith(optionInstance1Old, recordModel.b, optionInstance2Old);
+    expect(buildValuesAndSeparateKeysToObjectMapper).toHaveBeenCalledTimes(1);
+    expect(buildValuesAndSeparateKeysToObjectMapper).toHaveBeenCalledWith(allKeys, expect.any(Symbol));
+    expect(buildValuesAndSeparateKeysToObjectUnmapper).toHaveBeenCalledTimes(1);
+    expect(buildValuesAndSeparateKeysToObjectUnmapper).toHaveBeenCalledWith(allKeys, expect.any(Symbol));
+    expect(map).toHaveBeenCalledTimes(1);
+    expect(map).toHaveBeenCalledWith(mapper, unmapper);
+  });
+
+  it('should not wrap any arbitrary when required keys is not specified (all required) and forward all keys to mappers', () => {
+    // Arrange
+    const { instance: mappedInstance } = fakeNextArbitrary<any>();
+    const { instance: tupleInstance, map } = fakeNextArbitrary<any[]>();
+    const option = jest.spyOn(OptionMock, 'option');
+    const tuple = jest.spyOn(TupleMock, 'tuple');
+    tuple.mockReturnValue(convertFromNext(tupleInstance));
+    map.mockReturnValue(mappedInstance);
+
+    const mapper = jest.fn();
+    const buildValuesAndSeparateKeysToObjectMapper = jest.spyOn(
+      ValuesAndSeparateKeysToObjectMock,
+      'buildValuesAndSeparateKeysToObjectMapper'
+    );
+    buildValuesAndSeparateKeysToObjectMapper.mockReturnValue(mapper);
+
+    const unmapper = jest.fn();
+    const buildValuesAndSeparateKeysToObjectUnmapper = jest.spyOn(
+      ValuesAndSeparateKeysToObjectMock,
+      'buildValuesAndSeparateKeysToObjectUnmapper'
+    );
+    buildValuesAndSeparateKeysToObjectUnmapper.mockReturnValue(unmapper);
+
+    const arbKey1 = fakeNextArbitrary();
+    const arbKey2 = fakeNextArbitrary();
+    const recordModel = {
+      a: convertFromNext(arbKey1),
+      b: convertFromNext(arbKey2),
+    };
+    const requiredKeys = undefined;
+    const allKeys: (keyof typeof recordModel)[] = ['a', 'b'];
+
+    // Act
+    const arb = buildPartialRecordArbitrary(recordModel, requiredKeys);
+
+    // Assert
+    expect(convertToNext(arb)).toBe(mappedInstance);
+    expect(option).not.toHaveBeenCalled();
+    expect(tuple).toHaveBeenCalledTimes(1);
+    expect(tuple).toHaveBeenCalledWith(recordModel.a, recordModel.b);
+    expect(buildValuesAndSeparateKeysToObjectMapper).toHaveBeenCalledTimes(1);
+    expect(buildValuesAndSeparateKeysToObjectMapper).toHaveBeenCalledWith(allKeys, expect.any(Symbol));
+    expect(buildValuesAndSeparateKeysToObjectUnmapper).toHaveBeenCalledTimes(1);
+    expect(buildValuesAndSeparateKeysToObjectUnmapper).toHaveBeenCalledWith(allKeys, expect.any(Symbol));
+    expect(map).toHaveBeenCalledTimes(1);
+    expect(map).toHaveBeenCalledWith(mapper, unmapper);
+  });
+});
diff --git a/test/unit/arbitrary/_internals/mappers/ValuesAndSeparateKeysToObject.spec.ts b/test/unit/arbitrary/_internals/mappers/ValuesAndSeparateKeysToObject.spec.ts
new file mode 100644
index 00000000..27babb04
--- /dev/null
+++ b/test/unit/arbitrary/_internals/mappers/ValuesAndSeparateKeysToObject.spec.ts
@@ -0,0 +1,130 @@
+import {
+  buildValuesAndSeparateKeysToObjectMapper,
+  buildValuesAndSeparateKeysToObjectUnmapper,
+} from '../../../../../src/arbitrary/_internals/mappers/ValuesAndSeparateKeysToObject';
+
+describe('buildValuesAndSeparateKeysToObjectMapper', () => {
+  it('should create instances with Object prototype', () => {
+    // Arrange
+    const keys: (string | symbol)[] = [];
+    const magicNoValue = Symbol('no-value');
+    const values: any[] = [];
+
+    // Act
+    const mapper = buildValuesAndSeparateKeysToObjectMapper<any, typeof magicNoValue>(keys, magicNoValue);
+    const obj = mapper(values);
+
+    // Assert
+    expect(obj.constructor).toBe(Object);
+    expect(obj.__proto__).toBe(Object.prototype);
+  });
+
+  it('should replace magic values by no key', () => {
+    // Arrange
+    const keys: (string | symbol)[] = ['a', 'b', 'c', 'd'];
+    const magicNoValue = Symbol('no-value');
+    const values: any[] = [undefined, magicNoValue, null, 0];
+
+    // Act
+    const mapper = buildValuesAndSeparateKeysToObjectMapper<any, typeof magicNoValue>(keys, magicNoValue);
+    const obj = mapper(values);
+
+    // Assert
+    expect(obj).toHaveProperty('a');
+    expect(obj).not.toHaveProperty('b');
+    expect(obj).toHaveProperty('c');
+    expect(obj).toHaveProperty('d');
+    expect(obj).toEqual({ a: undefined, c: null, d: 0 });
+  });
+});
+
+describe('buildValuesAndSeparateKeysToObjectUnmapper', () => {
+  it('should properly unmap basic instances of Object without keys', () => {
+    // Arrange
+    const obj = {};
+    const keys: (string | symbol)[] = [];
+    const magicNoValue = Symbol('no-value');
+
+    // Act
+    const unmapper = buildValuesAndSeparateKeysToObjectUnmapper<any, typeof magicNoValue>(keys, magicNoValue);
+    const values = unmapper(obj);
+
+    // Assert
+    expect(values).toEqual([]);
+  });
+
+  it('should properly unmap basic instances of Object with multiple keys and no missing', () => {
+    // Arrange
+    const obj = { a: 'e', 1: 'hello', b: undefined };
+    const keys: (string | symbol)[] = ['b', '1', 'a'];
+    const magicNoValue = Symbol('no-value');
+
+    // Act
+    const unmapper = buildValuesAndSeparateKeysToObjectUnmapper<any, typeof magicNoValue>(keys, magicNoValue);
+    const values = unmapper(obj);
+
+    // Assert
+    expect(values).toEqual([undefined, 'hello', 'e']);
+  });
+
+  it('should properly unmap instances of Object with known symbols as keys', () => {
+    // Arrange
+    const s1 = Symbol('s1');
+    const s2 = Symbol('s2');
+    const obj = { a: 'e', [s2]: 'hello', [s1]: undefined };
+    const keys: (string | symbol)[] = [s2, s1, 'a'];
+    const magicNoValue = Symbol('no-value');
+
+    // Act
+    const unmapper = buildValuesAndSeparateKeysToObjectUnmapper<any, typeof magicNoValue>(keys, magicNoValue);
+    const values = unmapper(obj);
+
+    // Assert
+    expect(values).toEqual(['hello', undefined, 'e']);
+  });
+
+  it('should properly unmap instances of Object with missing keys', () => {
+    // Arrange
+    const s1 = Symbol('s1');
+    const s2 = Symbol('s2');
+    const s3 = Symbol('s2');
+    const obj = { a: 'e', [s2]: 'hello', [s1]: undefined };
+    const keys: (string | symbol)[] = [s2, 'b', s1, 'a', 'd', s3];
+    const magicNoValue = Symbol('no-value');
+
+    // Act
+    const unmapper = buildValuesAndSeparateKeysToObjectUnmapper<any, typeof magicNoValue>(keys, magicNoValue);
+    const values = unmapper(obj);
+
+    // Assert
+    expect(values).toEqual(['hello', magicNoValue, undefined, 'e', magicNoValue, magicNoValue]);
+  });
+
+  it.each`
+    value                                                                | condition
+    ${Object.create(null)}                                               | ${'it has no prototype'}
+    ${new (class A {})()}                                                | ${'it is not just a simple object but a more complex type'}
+    ${[]}                                                                | ${'it is an Array'}
+    ${new Number(0)}                                                     | ${'it is a boxed-Number'}
+    ${0}                                                                 | ${'it is a number'}
+    ${null}                                                              | ${'it is null'}
+    ${undefined}                                                         | ${'it is undefined'}
+    ${{ [Symbol('unknown')]: 5 }}                                        | ${'it contains an unknown symbol property'}
+    ${{ [Symbol('unknown')]: 5, a: 6, [Symbol.for('a')]: 7 }}            | ${'it contains an unknown symbol property (even if others are there)'}
+    ${{ unknown: 5 }}                                                    | ${'it contains an unknown named property'}
+    ${{ unknown: 5, a: 6, [Symbol.for('a')]: 7 }}                        | ${'it contains an unknown named property (even if others are there)'}
+    ${Object.defineProperty({}, 'a', { value: 5, configurable: false })} | ${'it contains a non-configurable property'}
+    ${Object.defineProperty({}, 'a', { value: 5, enumerable: false })}   | ${'it contains a non-enumerable property'}
+    ${Object.defineProperty({}, 'a', { value: 5, writable: false })}     | ${'it contains a non-writable property'}
+    ${Object.defineProperty({}, 'a', { get: () => 5 })}                  | ${'it contains a get property'}
+    ${Object.defineProperty({}, 'a', { set: () => {} })}                 | ${'it contains a set property'}
+  `('should reject unmap on instance when $condition', ({ value }) => {
+    // Arrange
+    const keys: (string | symbol)[] = ['a', Symbol.for('a')];
+    const magicNoValue = Symbol('no-value');
+    const unmapper = buildValuesAndSeparateKeysToObjectUnmapper<any, typeof magicNoValue>(keys, magicNoValue);
+
+    // Act / Assert
+    expect(() => unmapper(value)).toThrowError();
+  });
+});
diff --git a/test/unit/arbitrary/record.spec.ts b/test/unit/arbitrary/record.spec.ts
new file mode 100644
index 00000000..db7430c9
--- /dev/null
+++ b/test/unit/arbitrary/record.spec.ts
@@ -0,0 +1,292 @@
+import fc from '../../../lib/fast-check';
+import { record, RecordConstraints } from '../../../src/arbitrary/record';
+import { Arbitrary } from '../../../src/check/arbitrary/definition/Arbitrary';
+import { convertFromNext, convertToNext } from '../../../src/check/arbitrary/definition/Converters';
+import { FakeIntegerArbitrary, fakeNextArbitrary } from '../check/arbitrary/generic/NextArbitraryHelpers';
+import {
+  assertGenerateProducesSameValueGivenSameSeed,
+  assertGenerateProducesCorrectValues,
+  assertGenerateProducesValuesFlaggedAsCanGenerate,
+  assertShrinkProducesSameValueWithoutInitialContext,
+  assertShrinkProducesCorrectValues,
+  assertShrinkProducesValuesFlaggedAsCanGenerate,
+} from '../check/arbitrary/generic/NextArbitraryAssertions';
+
+import * as PartialRecordArbitraryBuilderMock from '../../../src/arbitrary/_internals/builders/PartialRecordArbitraryBuilder';
+
+function beforeEachHook() {
+  jest.resetModules();
+  jest.restoreAllMocks();
+  fc.configureGlobal({ beforeEach: beforeEachHook });
+}
+beforeEach(beforeEachHook);
+
+describe('record', () => {
+  const keyArb: fc.Arbitrary<any> = fc
+    .tuple(fc.string(), fc.boolean())
+    .map(([name, symbol]) => (symbol ? Symbol.for(name) : name));
+
+  it('should call buildPartialRecordArbitrary with keys=undefined when no constraints on keys', () =>
+    fc.assert(
+      fc.property(
+        fc.set(keyArb, { minLength: 1 }),
+        fc.constantFrom(...([undefined, {}] as const)),
+        (keys, constraints) => {
+          // Arrange
+          const recordModel: Record<string | symbol, Arbitrary<any>> = {};
+          for (const k of keys) {
+            const { instance } = fakeNextArbitrary();
+            recordModel[k] = convertFromNext(instance);
+          }
+          const { instance } = fakeNextArbitrary<any>();
+          const buildPartialRecordArbitrary = jest.spyOn(
+            PartialRecordArbitraryBuilderMock,
+            'buildPartialRecordArbitrary'
+          );
+          buildPartialRecordArbitrary.mockReturnValue(convertFromNext(instance));
+
+          // Act
+          const arb = constraints !== undefined ? record(recordModel, constraints) : record(recordModel);
+
+          // Assert
+          expect(convertToNext(arb)).toBe(instance);
+          expect(buildPartialRecordArbitrary).toHaveBeenCalledTimes(1);
+          expect(buildPartialRecordArbitrary).toHaveBeenCalledWith(recordModel, undefined);
+        }
+      )
+    ));
+
+  it('should call buildPartialRecordArbitrary with keys=[] when constraints defines withDeletedKeys=true', () =>
+    fc.assert(
+      fc.property(fc.set(keyArb, { minLength: 1 }), (keys) => {
+        // Arrange
+        const recordModel: Record<string | symbol, Arbitrary<any>> = {};
+        for (const k of keys) {
+          const { instance } = fakeNextArbitrary();
+          recordModel[k] = convertFromNext(instance);
+        }
+        const { instance } = fakeNextArbitrary<any>();
+        const buildPartialRecordArbitrary = jest.spyOn(
+          PartialRecordArbitraryBuilderMock,
+          'buildPartialRecordArbitrary'
+        );
+        buildPartialRecordArbitrary.mockReturnValue(convertFromNext(instance));
+
+        // Act
+        const arb = record(recordModel, { withDeletedKeys: true });
+
+        // Assert
+        expect(convertToNext(arb)).toBe(instance);
+        expect(buildPartialRecordArbitrary).toHaveBeenCalledTimes(1);
+        expect(buildPartialRecordArbitrary).toHaveBeenCalledWith(recordModel, []);
+      })
+    ));
+
+  it('should call buildPartialRecordArbitrary with keys=requiredKeys when constraints defines valid requiredKeys', () =>
+    fc.assert(
+      fc.property(fc.set(keyArb, { minLength: 1 }), fc.func(fc.boolean()), (keys, isRequired) => {
+        // Arrange
+        const recordModel: Record<string | symbol, Arbitrary<any>> = {};
+        const requiredKeys: any[] = [];
+        for (const k of keys) {
+          const { instance } = fakeNextArbitrary();
+          recordModel[k] = convertFromNext(instance);
+          if (isRequired(k)) {
+            requiredKeys.push(k);
+          }
+        }
+        const { instance } = fakeNextArbitrary<any>();
+        const buildPartialRecordArbitrary = jest.spyOn(
+          PartialRecordArbitraryBuilderMock,
+          'buildPartialRecordArbitrary'
+        );
+        buildPartialRecordArbitrary.mockReturnValue(convertFromNext(instance));
+
+        // Act
+        const arb = record(recordModel, { requiredKeys });
+
+        // Assert
+        expect(convertToNext(arb)).toBe(instance);
+        expect(buildPartialRecordArbitrary).toHaveBeenCalledTimes(1);
+        expect(buildPartialRecordArbitrary).toHaveBeenCalledWith(recordModel, requiredKeys);
+      })
+    ));
+
+  it('should reject configurations specifying non existing keys as required', () =>
+    fc.assert(
+      fc.property(fc.set(keyArb, { minLength: 1 }), keyArb, (keys, requiredKey) => {
+        // Arrange
+        fc.pre(!keys.includes(requiredKey));
+        const recordModel: Record<string | symbol, Arbitrary<any>> = {};
+        for (const k of keys) {
+          const { instance } = fakeNextArbitrary();
+          recordModel[k] = convertFromNext(instance);
+        }
+
+        // Act / Assert
+        expect(() =>
+          record(recordModel, {
+            requiredKeys: [requiredKey],
+          })
+        ).toThrowError();
+      })
+    ));
+
+  it('should reject configurations specifying both requiredKeys and withDeletedKeys (even undefined)', () =>
+    fc.assert(
+      fc.property(
+        fc.set(fc.record({ name: keyArb, required: fc.boolean() }), {
+          minLength: 1,
+          compare: (a, b) => a.name === b.name,
+        }),
+        fc.option(fc.constant(true), { nil: undefined }),
+        fc.option(fc.boolean(), { nil: undefined }),
+        (keys, withRequiredKeys, withDeletedKeys) => {
+          // Arrange
+          const recordModel: Record<string | symbol, Arbitrary<any>> = {};
+          for (const k of keys) {
+            const { instance } = fakeNextArbitrary();
+            recordModel[k.name] = convertFromNext(instance);
+          }
+
+          // Act / Assert
+          expect(() =>
+            record(recordModel, {
+              requiredKeys: withRequiredKeys ? keys.filter((k) => k.required).map((k) => k.name) : undefined,
+              withDeletedKeys: withDeletedKeys,
+            })
+          ).toThrowError();
+        }
+      )
+    ));
+
+  it('should accept empty keys configurations with empty requiredKeys', () => {
+    // Arrange
+    const recordModel: Record<string | symbol, Arbitrary<any>> = {};
+
+    // Act / Assert
+    expect(() => record(recordModel, { requiredKeys: [] })).not.toThrowError();
+  });
+
+  it.each`
+    keyName
+    ${'constructor'}
+    ${'toString'}
+    ${'__proto__'}
+  `('should reject configurations specifying non own properties ($keyName) as requiredKeys', ({ keyName }) => {
+    // Arrange
+    const recordModel: Record<string | symbol, Arbitrary<any>> = {};
+
+    // Act / Assert
+    expect(() => record(recordModel, { requiredKeys: [keyName] })).toThrowError();
+  });
+
+  it('should reject configurations specifying non enumerable properties as requiredKeys', () => {
+    // Arrange
+    const keyName = 'k';
+    const recordModel: Record<string | symbol, Arbitrary<any>> = {};
+    Object.defineProperty(recordModel, keyName, { value: fc.boolean(), enumerable: false });
+
+    // Act / Assert
+    expect(() => record(recordModel, { requiredKeys: [keyName] })).toThrowError();
+  });
+});
+
+describe('record (integration)', () => {
+  type Meta = { key: any; valueStart: number; kept: boolean };
+  type Extra = [Meta[], RecordConstraints<any>];
+
+  const keyArb: fc.Arbitrary<any> = fc
+    .tuple(fc.string(), fc.boolean())
+    .map(([name, symbol]) => (symbol ? Symbol.for(name) : name));
+  const metaArbitrary = fc.set(
+    fc.record({
+      key: keyArb,
+      valueStart: fc.nat(1000),
+      kept: fc.boolean(),
+    }),
+    { compare: (v1, v2) => v1.key === v2.key }
+  );
+  const constraintsArbitrary = fc.oneof(
+    fc.record({ withDeletedKeys: fc.boolean() }, { requiredKeys: [] }),
+    fc.record({ withRequiredKeys: fc.constant<true>(true) }, { requiredKeys: [] })
+  );
+  const extraParameters: fc.Arbitrary<Extra> = fc
+    .tuple(metaArbitrary, constraintsArbitrary)
+    .map(([metas, constraintsMeta]: [Meta[], { withDeletedKeys?: boolean; withRequiredKeys?: true }]) => {
+      if ('withRequiredKeys' in constraintsMeta) {
+        return [metas, { requiredKeys: metas.filter((m) => m.kept).map((m) => m.key) }] as [Meta[], RecordConstraints];
+      }
+      return [metas, constraintsMeta] as [Meta[], RecordConstraints];
+    });
+
+  const isCorrect = (value: any, extra: Extra) => {
+    const [metas, constraints] = extra;
+    // Rq: getOwnPropertyNames will also get non enumerable properties, but there are none in our case
+    for (const k of [...Object.getOwnPropertyNames(value), ...Object.getOwnPropertySymbols(value)]) {
+      // generated object should not have more keys
+      if (metas.findIndex((m) => m.key === k) === -1) return false;
+    }
+    for (const m of metas) {
+      // optional keys can be missing in the generated instance
+      if (
+        'withDeletedKeys' in constraints &&
+        constraints.withDeletedKeys === true &&
+        !Object.prototype.hasOwnProperty.call(value, m.key)
+      ) {
+        continue;
+      }
+      if (
+        'requiredKeys' in constraints &&
+        constraints.requiredKeys !== undefined &&
+        !constraints.requiredKeys.includes(m.key) &&
+        !Object.prototype.hasOwnProperty.call(value, m.key)
+      ) {
+        continue;
+      }
+      // values are associated to the right key (if key required)
+      if (typeof value[m.key] !== 'number') return false;
+      if (value[m.key] < m.valueStart) return false;
+      if (value[m.key] > m.valueStart + 10) return false;
+    }
+    return true;
+  };
+
+  const recordBuilder = (extra: Extra) => {
+    const [metas, constraints] = extra;
+    const recordModel: Record<string | symbol, Arbitrary<number>> = {};
+    for (const m of metas) {
+      const instance = new FakeIntegerArbitrary(m.valueStart, 10);
+      recordModel[m.key] = convertFromNext(instance);
+    }
+    return convertToNext(record(recordModel, constraints));
+  };
+
+  it('should generate the same values given the same seed', () => {
+    assertGenerateProducesSameValueGivenSameSeed(recordBuilder, { extraParameters });
+  });
+
+  it('should only generate correct values', () => {
+    assertGenerateProducesCorrectValues(recordBuilder, isCorrect, { extraParameters });
+  });
+
+  it('should recognize values that would have been generated using it during generate', () => {
+    assertGenerateProducesValuesFlaggedAsCanGenerate(recordBuilder, { extraParameters });
+  });
+
+  it('should shrink towards the same values given the same seed', () => {
+    assertGenerateProducesSameValueGivenSameSeed(recordBuilder, { extraParameters });
+  });
+
+  it('should be able to shrink without any context', () => {
+    assertShrinkProducesSameValueWithoutInitialContext(recordBuilder, { extraParameters });
+  });
+
+  it('should only shrink towards correct values', () => {
+    assertShrinkProducesCorrectValues(recordBuilder, isCorrect, { extraParameters });
+  });
+
+  it('should recognize values that would have been generated using it during shrink', () => {
+    assertShrinkProducesValuesFlaggedAsCanGenerate(recordBuilder, { extraParameters });
+  });
+});
diff --git a/test/unit/check/arbitrary/definition/NextArbitrary.utest.spec.ts b/test/unit/check/arbitrary/definition/NextArbitrary.utest.spec.ts
index e173d875..8c71fd90 100644
--- a/test/unit/check/arbitrary/definition/NextArbitrary.utest.spec.ts
+++ b/test/unit/check/arbitrary/definition/NextArbitrary.utest.spec.ts
@@ -458,7 +458,7 @@ describe('NextArbitrary', () => {
         new NextValue('tutu')
       );
       const generate = jest.fn();
-      const canShrinkWithoutContext = jest.fn().mockReturnValue(true);
+      const canShrinkWithoutContext = jest.fn();
       const shrink = jest.fn().mockReturnValueOnce(expectedStreamValuesFromSource);
       const originalValue = Symbol();
       const unmapperOutput = 'tata';
@@ -477,68 +477,9 @@ describe('NextArbitrary', () => {
       expect(shrinks.map((s) => s.value)).toEqual([Symbol.for('titi'), Symbol.for('toto'), Symbol.for('tutu')]);
       expect(unmapper).toHaveBeenCalledTimes(1);
       expect(unmapper).toHaveBeenCalledWith(originalValue);
-      expect(canShrinkWithoutContext).toHaveBeenCalledTimes(1);
-      expect(canShrinkWithoutContext).toHaveBeenCalledWith(unmapperOutput);
       expect(shrink).toHaveBeenCalledTimes(1);
       expect(shrink).toHaveBeenCalledWith(unmapperOutput);
     });
-
-    it('should return an empty stream if source arbitrary tells it cannot generate the value', () => {
-      // Arrange
-      const expectedStreamValuesFromSource = Stream.of(
-        new NextValue('titi'),
-        new NextValue('toto'),
-        new NextValue('tutu')
-      );
-      const generate = jest.fn();
-      const canShrinkWithoutContext = jest.fn().mockReturnValue(false);
-      const shrink = jest.fn().mockReturnValueOnce(expectedStreamValuesFromSource);
-      const originalValue = Symbol();
-      const unmapperOutput = 'tata';
-      const unmapper = jest.fn().mockReturnValue('tata');
-      class MyNextArbitrary extends NextArbitrary<any> {
-        generate = generate;
-        canShrinkWithoutContext = canShrinkWithoutContext as any as (value: unknown) => value is any;
-        shrink = shrink;
-      }
-
-      // Act
-      const arb = new MyNextArbitrary().map((tag) => Symbol.for(tag), unmapper);
-      const shrinks = [...arb.shrink(originalValue)];
-
-      // Assert
-      expect(shrinks.map((s) => s.value)).toEqual([]);
-      expect(unmapper).toHaveBeenCalledTimes(1);
-      expect(unmapper).toHaveBeenCalledWith(originalValue);
-      expect(canShrinkWithoutContext).toHaveBeenCalledTimes(1);
-      expect(canShrinkWithoutContext).toHaveBeenCalledWith(unmapperOutput);
-      expect(shrink).not.toHaveBeenCalled();
-    });
-
-    it('should not catch exceptions emit by unmapper if unmapper throws (shrink should be called with supported values)', () => {
-      // Arrange
-      const expectedStreamValuesFromSource = Stream.of(
-        new NextValue('titi'),
-        new NextValue('toto'),
-        new NextValue('tutu')
-      );
-      const generate = jest.fn();
-      const canShrinkWithoutContext = jest.fn().mockReturnValue(false);
-      const shrink = jest.fn().mockReturnValueOnce(expectedStreamValuesFromSource);
-      const originalValue = Symbol();
-      const unmapper = jest.fn().mockImplementation(() => {
-        throw new Error('Unsupported value, cannot unmap');
-      });
-      class MyNextArbitrary extends NextArbitrary<any> {
-        generate = generate;
-        canShrinkWithoutContext = canShrinkWithoutContext as any as (value: unknown) => value is any;
-        shrink = shrink;
-      }
-
-      // Act / Assert
-      const arb = new MyNextArbitrary().map((tag) => Symbol.for(tag), unmapper);
-      expect(() => arb.shrink(originalValue)).toThrowError();
-    });
   });
 
   describe('chain', () => {