Assign/Copy only specified Attributes to Object (TS/JS)

Viewed 63

Would it be possible to have a copy operation like Object.assign(...) to copy only known Properties to the destination?

My sample code looks like:

class A {
    foo?: string;
    constructor(p: any) {
        Object.assign(this, p);
    }
}

const instance = new A({
    foo: 'test',
    bar: 'other'
});

console.log(instance); // yields:     A: { "foo": "test", "bar": "other" }
                       // but i want: A: { "foo": "test" }

I know that typings are removed in JS but wonder if it would still be possible with something like decorators.

Checking with .hasOwnProperty or similar is not an option because it should allow copy of unset properties like in the example above.

2 Answers

There is no concept of reflection or introspection in TypeScript. So one approach would be to add metadata to your type and roll your own version of assign that uses it.

const MAPPED_TYPE_METADATA_KEY = Symbol('mappedType');
type TypeOfType = 'string' | 'number' | 'bigint' | 'boolean' | 'function' | 'object';

/** Decorator that adds mapped types to an object. */
function mappedType(typeName: TypeOfType) {
  return function(target: any, propertyKey: string): void {
    const typeMap = { ...Reflect.get(target, MAPPED_TYPE_METADATA_KEY), [propertyKey]: typeName };
    Reflect.set(target, MAPPED_TYPE_METADATA_KEY, typeMap);
    Reflect.defineMetadata(MAPPED_TYPE_METADATA_KEY, typeMap, target);
  };
}
/** Uses mappedTypes to assign values to an object. */ 
function customAssign<T>(obj: T, data: Partial<{ [key in keyof A]: A[key] }>): void {
  const typeMap: Record<string | number | symbol, TypeOfType> | undefined = Reflect.get(obj, MAPPED_TYPE_METADATA_KEY);
  if (typeMap) {
    Object.entries(data)
      .filter(([key, value]) => typeMap[key as keyof T] === typeof value)
      .forEach(([key, value]) => (obj as any)[key] = value);
  }
}
class A {
  @mappedType('string')
  foo?: string;
  @mappedType('number')
  another: number = 1; // added for testing

  constructor(data: any) {
    customAssign(this, data);
  }
}

const instance = new A({
  foo: 'test',
  bar: 'other'
}); 

console.log(instance);  // outputs {another: 1, foo: 'test'} // another defaults to 1 and not accidentally overwritten.

The solution encompasses the following features.

  • A mapepdType decorator that creates a type map that's keyed by the field name and mapped to the type of the value. The danger here is that there is nothing enforcing that the types match.
  • A customAssign function that works like assign but incorperates the a _typeMap field created by the decorators so that the values are properly assigned.
  • Object.assign is replaced in the constructor with customAssign.

The output matches your desired output. Additionally values won't get overwritten that were assigned by default.

EDIT

After a little work, I figured out a type "safish" version.

type TypeOfType = 'string' | 'number' | 'bigint' | 'boolean' | 'function' | 'object';
type TypeOfTypeType<T extends TypeOfType> =
  T extends 'string' ? string 
  : T extends 'number' ? number
  : T extends 'bigint' ? bigint
  : T extends 'function' ? Function
  : T extends 'object' ? object
  : never;

function mappedType<T extends TypeOfTypeType<TT>, TT extends TypeOfType>(typeName: TT) {
  return function<U extends object, V extends keyof U>(target: U, propertyKey: U[V] extends (T | undefined) ? V : never): void {
    const typeMap = { ...Reflect.get(target, MAPPED_TYPE_METADATA_KEY), [propertyKey]: typeName };
    Reflect.set(target, MAPPED_TYPE_METADATA_KEY, typeMap);
    Reflect.defineMetadata(MAPPED_TYPE_METADATA_KEY, typeMap, target);
  };
}

This will throw a compile time error if the corresponding type to the string passed to the mappedType decorator doesn't match a type that the type of the decorated property extends (Dang! That's a confusing sentence). The follow example should help illustrate what I mean.

class A {
  @mappedType('string') a?: string; // okay
  @mappedType('number') b: number = 1; // okay
  @mappedType('object') c?: AnotherClass; // okay
  @mappedType('object') d: string[] = []; // okay
  @mappedType('number') e?: string; // bad!
  @mappedType('object') f: number = 1; // bad!
  @mappedType('function') g?: AnotherClass; // bad!
  @mappedType('string') h: string[] = []; // bad!
}

If you want the resulting object to keep only the properties of the A class, then you can try this:

class A {
    foo?: string;

    constructor(p: A | any) {
        this.foo = typeof p === 'undefined' || typeof p.foo !== 'string' ? '' : p.foo;
    }
}

For a class with many properties:

class A {
    foo?: string;
    bar?: string;
    foo1!: number;
    bar1!: number;

    constructor(p: A | any) {
        Object.getOwnPropertyNames(this).forEach(key => {
            if (typeof p !== 'undefined' && typeof p[key] !== 'undefined') {
                this[key] = p[key];
            }
        });
    }
}

Third option, needs explicit default values on the parameters, also used workaround for the error "Type 'any' is not assignable to type 'never'.":

class A {
    foo?: string = '';
    bar?: string = '';
    foo1: number = 0;
    bar1: number = 0;

    constructor(p: A | any) {
        let self: A = this;
        let selfKeys = Object.getOwnPropertyNames(self) as Array<keyof A>;
        let pKeys = Object.getOwnPropertyNames(p) as Array<keyof typeof p>;

        selfKeys.filter(k => pKeys.filter((pk) => pk == k).length > 0).forEach((key) => self[key] = (p[key] as never));
    }
}

const i = new A({
    foo: 'test',
    qux: 'not',
});

console.log(i);

For the fouth option, you can simply delete the optional properties that are still undefined after the assignment. I have done it all on the constructor, but this can become easily a separate method:

class A {
    foo?: string = undefined;
    bar?: string = undefined;
    foo1: number = 0;
    bar1: number = 0;

    constructor(p: A | any) {
        let self: A = this;
        let selfKeys = Object.getOwnPropertyNames(self) as Array<keyof A>;
        let pKeys = Object.getOwnPropertyNames(p) as Array<keyof typeof p>;

        selfKeys.filter(k => pKeys.filter((pk) => pk == k).length > 0).forEach((key) => self[key] = (p[key] as never));

        selfKeys.forEach(k => {
            if (self[k] === undefined) {
                delete self[k];
            }
        });
    }
}

const i = new A({
    foo: 'test',
    qux: 'not',
});

console.log(i);
Related