Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

Tags:

typescript

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.

like image 868
mptr Avatar asked Feb 18 '26 23:02

mptr


1 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!
}
like image 199
Daniel Gimenez Avatar answered Feb 21 '26 13:02

Daniel Gimenez