Skip to content

Instantly share code, notes, and snippets.

@simonrelet
Last active May 2, 2019 17:00
Show Gist options
  • Save simonrelet/078eeae23b1a2a5d91e01ad018a06dcb to your computer and use it in GitHub Desktop.
Save simonrelet/078eeae23b1a2a5d91e01ad018a06dcb to your computer and use it in GitHub Desktop.
Bidirectional object mapping
/**
* @typedef {object} Mapper
* @property {function(any): any} fromAPI
* @property {function(any): any} toAPI
*
* @typedef {object} Config
* @property {string} [name]
* @property {Mapper} [mapper]
*
* @typedef {Object.<string, string | Config>} MixedMappings
* @typedef {Object.<string, Config>} Mappings
*/
/**
* Create a Mapper that can transform objects.
*
* @param {MixedMappings} mappings
* @returns {Mapper}
*/
export function createAPIMapper(mappings) {
mappings = ensureConfigAsObject(mappings)
const reversedMappings = reverseMappings(mappings)
return {
fromAPI: obj => map(obj, mappings, 'fromAPI'),
toAPI: obj => map(obj, reversedMappings, 'toAPI'),
}
}
/**
* @param {MixedMappings} mapping
* @returns {Mappings}
*/
function ensureConfigAsObject(mapping) {
return Object.entries(mapping).reduce((acc, [key, config]) => {
return typeof config === 'object'
? { ...acc, [key]: ensureName(config, key) }
: { ...acc, [key]: { name: config } }
}, {})
}
/**
* @param {Config} config
* @param {string} key
* @returns {Config}
*/
function ensureName(config, key) {
return config.name == null ? { ...config, name: key } : config
}
/**
* @param {Mappings} mappings
* @returns {Mappings}
*/
function reverseMappings(mappings) {
return Object.entries(mappings).reduce(
(acc, [key, config]) => ({
...acc,
[config.name]: { ...config, name: key },
}),
{},
)
}
/**
* @param {any} obj
* @param {Mappings} mappings
* @param {string} fnName
*/
function map(obj, mappings, fnName) {
if (obj == null) {
return null
}
return Object.entries(obj).reduce((acc, [key, value]) => {
const config = mappings[key]
let newKey = key
let newValue = value
if (config != null) {
newKey = config.name
const mapper = config.mapper
if (mapper != null) {
const fn = mapper[fnName]
newValue = Array.isArray(value) ? value.map(fn) : fn(value)
}
}
return { ...acc, [newKey]: newValue }
}, {})
}
/**
* Ensure that a value is a number.
*
* @type {Mapper}
*/
export const NumberMapper = {
// Using `+` rather than parseInt or parseFloat is typically faster, though
// more restrictive.
// https://stackoverflow.com/questions/17106681/parseint-vs-unary-plus-when-to-use-which/17106702#17106702
fromAPI: number => {
let value = NaN
// An empty string is not a valid number.
// We need a special case because `+''` gives `0`.
if (number !== '') {
value = +number
}
return isNaN(value) ? null : value
},
toAPI: number => number,
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment