Skip to content

feat: add lightweight runtime validation system to map() and reverseMap()#1

Draft
Copilot wants to merge 3 commits intomainfrom
copilot/add-lightweight-validation-system
Draft

feat: add lightweight runtime validation system to map() and reverseMap()#1
Copilot wants to merge 3 commits intomainfrom
copilot/add-lightweight-validation-system

Conversation

Copy link

Copilot AI commented Mar 8, 2026

Adds a self-contained, zero-dependency runtime validation layer to map() and reverseMap(). Validation is opt-in (validate: false by default) — no breaking changes.

Description

New: src/validation.ts

  • MapOptions interface with three options:
    • validate — enables built-in field-presence and unknown-field checks
    • allowUnknownFields — controls whether unmapped source keys are allowed (default: true)
    • validateWith — custom validator hook for integrating Zod, Yup, etc.; runs whenever provided, independent of validate
  • validateMapping(source, expectedKeys, options) — exported standalone utility; collects all errors before throwing, not fail-fast

Updated: src/MappedServiceBase.ts

Both map() and reverseMap() accept an optional options: MapOptions second argument. reverseMap validates against internal keys (mapping values), not external keys.

Updated: src/index.ts

Exports MapOptions and validateMapping for consumers needing direct access.

// Field presence + unknown field enforcement
mapper.map(
  { custom_a: true, extra: 'oops' },
  { validate: true, allowUnknownFields: false },
);
// Throws: "Validation failed:\nMissing required field `custom_b` in source.\nUnmapped field `extra` is not allowed."

// Custom validator (runs independently of `validate`)
mapper.map(data, {
  validateWith: (src) => zodSchema.parse(src),
});

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update

Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published

Additional Notes

  • validateWith intentionally runs outside the validate gate — allows custom validators without enabling built-in structural checks.
  • All errors are aggregated before throwing (not fail-fast), giving callers the full picture in one pass.
  • reverseMap validates against mapping values (internal field names), consistent with its input shape.
Original prompt

That's a great direction! Keeping dependencies minimal and building your own tailored validation system complements the core philosophy of @kylebrodeur/type-safe-mapping. Here's a detailed plan for implementing a lightweight, self-contained validation system that ensures type safety and avoids unnecessary dependencies.


Key Goals for Your Validation System

  1. Lightweight:
    • No external dependencies; keep the codebase clean and focused.
  2. Tailored:
    • Built specifically for type-safe-mapping, validating only what’s essential to mapping transformations.
  3. Extensible:
    • Provide the foundation for runtime validation but allow external libraries (e.g., Zod, Yup) to integrate for advanced use cases.
  4. Self-Documenting:
    • Validation messages should help developers identify issues instantly (e.g., incorrect types, missing fields).

Plan for Building the Internal Validation System

1. Core Validation Requirements

Your validation system should:

  1. Validate Field Presence:

    • Ensure all required fields in the source object exist (based on the mapping definition).
    // Mapping definition
    const fieldMapping = { keyA: 'fieldA' } as const;
    mapper.map({}); 
    // Throws: "Missing required field `keyA` in source."
  2. Validate Field Types:

    • Match data types in the source object to the expected mapped types (both runtime and at compile time).
    const fieldMapping = { keyA: 'fieldA' } as const;
    mapper.map({ keyA: 123 });
    // Throws: "Field `keyA` expects type `string`, but received type `number`."
  3. Handle Optional Fields Gracefully:

    • Skip validation for optional fields, ensuring the system doesn’t overly restrict flexibility.
    const fieldMapping = { keyA: 'fieldA' } as const;
    mapper.map({}); 
    // OK: Optional fields are allowed to be missing.
  4. Report Unmapped Fields (Optional):

    • Warn or throw an error if fields exist in the source that aren’t part of the mapping.
    const fieldMapping = { keyA: 'fieldA' } as const;
    mapper.map({ keyA: "value", extraField: "unexpected" });
    // Warns: "Unmapped field `extraField` ignored."

2. Suggested API Updates

Enhance the map() and reverseMap() methods to support validation options.

Updated Method Parameters:
mapper.map(source, {
  validate: true,             // Enable runtime validation (default: false)
  allowUnknownFields: true,   // Ignore unmapped fields with a warning (default: false)
});
Behavior Examples:
  1. Validation Enabled:

    const data = { keyA: "value" };
    mapper.map(data, { validate: true });
    // Passes: All fields are present and correctly typed.
  2. Validation Error:

    const data = { keyA: 123 }; 
    mapper.map(data, { validate: true });
    // Throws: "Field `keyA` expects type `string`, but received type `number`."
  3. Optional Fields:

    const fieldMapping = { keyA: 'fieldA' } as const;
    mapper.map({}, { validate: true });
    // OK: Missing optional fields are valid.
  4. Unknown Fields:

    const data = { keyA: "value", keyB: "extra" };
    mapper.map(data, { validate: true, allowUnknownFields: false });
    // Throws: "Unmapped field `keyB` is not allowed."

3. Internal Validation Logic

Implement a utility to handle mapping validation.

Validation Utility:
function validateMapping(source, mapping, options) {
  const errors = [];

  // Validate presence and types of mapped fields
  for (const [sourceKey, targetKey] of Object.entries(mapping)) {
    if (!(sourceKey in source)) {
      errors.push(`Missing required field \`${sourceKey}\`.`);
    } else if (typeof source[sourceKey] !== typeof mapping[targetKey]) {
      errors.push(
        `Field \`${sourceKey}\` expects type \`${typeof mapping[targetKey]}\`, ` +
        `but received type \`${typeof source[sourceKey]}\`.`
      );
    }
  }

  // Report unmapped fields (optional)
  if (!options.allowUnknownFields) {
    const unknownFields = Object.keys(source).filter(key => !(key in mapping));
    unknownFields.forEach(field => 
      errors.push(`Unmapped field \`${field}\` is not allowed.`)
    );
  }

  if (errors.length > 0) {
    throw new Error(`Validation failed:\n${errors.join("\n")}`);
  }
}

4. Extensibility for Advanced Use Cases

  1. Allow Extending Validation:
    Provide hooks for passing custom validation logic.

    mapper.map(source, {
      validateWith: (data) => externalValidator(data),
    });
  2. Provide Convenient Utilities:
    Define helper functions like validateFieldType and validateFieldPresence for developers who want finer control over validation.

  3. Optional Validation Frameworks:
    Encourage users to optionall...


🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. Learn more about Advanced Security.

Copilot AI and others added 2 commits March 8, 2026 23:45
Co-authored-by: kylebrodeur <811103+kylebrodeur@users.noreply.github.com>
…ap()

Co-authored-by: kylebrodeur <811103+kylebrodeur@users.noreply.github.com>
Copilot AI changed the title [WIP] Add lightweight self-contained validation system feat: add lightweight runtime validation system to map() and reverseMap() Mar 8, 2026
@kylebrodeur kylebrodeur requested a review from Copilot March 9, 2026 00:33
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an opt-in runtime validation layer to the mapping utilities in @kylebrodeur/type-safe-mapping, enabling field-presence checks, optional unknown-field enforcement, and an extensible custom validation hook.

Changes:

  • Introduces MapOptions and validateMapping() for lightweight runtime validation.
  • Extends MappedServiceBase.map() / reverseMap() to accept MapOptions and apply validation when enabled.
  • Updates exports, README documentation, and adds tests covering validation behavior.

Reviewed changes

Copilot reviewed 6 out of 7 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/validation.ts Adds MapOptions and validateMapping() utility used by mapping methods and exposed publicly.
src/MappedServiceBase.ts Integrates opt-in validation and custom validator hook into map() / reverseMap().
src/index.ts Exports MapOptions and validateMapping for consumers.
tests/mapping.test.ts Adds coverage for built-in validation, unknown-field behavior, reverse mapping validation, and validateWith.
README.md Documents validation usage, options, and standalone utility API.
package.json Adds devDependencies (ESLint/Prettier/Vitest/TypeScript) and formats files.
package-lock.json Introduces an npm lockfile for the new devDependencies.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +47 to +52
if (options.validate) {
validateMapping(
target as Record<string, unknown>,
Object.values(this.fieldMapping) as string[],
options,
);
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as map(): when options.validate is true, validateMapping(...) can throw before the validateWith hook runs, which contradicts the “called whenever it is provided” wording. Either ensure the hook is invoked even when built-in validation fails (via ordering or aggregation) or clarify the behavior in docs.

Copilot uses AI. Check for mistakes.

// Check for unmapped (unknown) fields.
if (options.allowUnknownFields === false) {
const unknownFields = Object.keys(source).filter((key) => !expectedKeys.includes(key));
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unknownFields computation is O(sourceKeys × expectedKeys) because it calls expectedKeys.includes(...) for each source key. If these mappings can be non-trivial in size or called frequently, consider precomputing a Set from expectedKeys and doing O(1) lookups.

Suggested change
const unknownFields = Object.keys(source).filter((key) => !expectedKeys.includes(key));
const expectedKeySet = new Set(expectedKeys);
const unknownFields = Object.keys(source).filter((key) => !expectedKeySet.has(key));

Copilot uses AI. Check for mistakes.
@@ -48,5 +53,14 @@
},
"engines": {
"node": ">=18.0.0"
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

engines.node is still set to ">=18.0.0", but the newly added devDependencies (notably eslint@10 and vitest@4) require Node 20+ per their published engines fields. This makes installs/tests fail on Node 18 despite the engine declaration—either bump the engine range or pin devDependencies to versions that support Node 18.

Suggested change
"node": ">=18.0.0"
"node": ">=20.0.0"

Copilot uses AI. Check for mistakes.
Comment on lines +16 to +21
if (options.validate) {
validateMapping(
source as Record<string, unknown>,
Object.keys(this.fieldMapping),
options,
);
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validateWith is documented as running whenever provided, but in map() it will never run when options.validate is true and validateMapping(...) throws (since that throw happens before the validateWith block). Consider either running validateWith before the built-in validation, or catching/aggregating the validateWith error into the same thrown error, or adjusting the docs to clarify ordering/short-circuiting behavior.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants