Redeclare class generic

Viewed 42

I've created a class in TypeScript which uses a generic to allow for method type checking. To simplify:

class Schema<PropType extends Record<string, any>> {
  props: Partial<PropType> = {};
  
  constructor(props: PropType) {
    this.props = props;
  }

  pick(...propNames: Array<keyof PropType>) {
    return new Schema(propNames.reduce(
      (obj, key) => ({ ...obj, [key]: this.props[key] }),
      {}
    ));
  }

  set(key: string, value: any) {
    return new Schema({ ...this.props, [key]: value });
  }
}

The purpose of this is to provide type-safety when calling pick, or one of the other methods (not shown here), where prop keys are needed:

const Person = new Schema({ firstName: 'Fyodor', lastName: 'Dostoevsky' });
Person.pick('firstName'); // No issues
Person.pick('madeup'); // Issues

The problem is, when I change the object and declare a new class, it doesn't seem to extend a new PropType type:

const Person = new Schema({ firstName: 'Fyodor', lastName: 'Dostoevsky' });
Person.pick('firstName'); // Works
Person.pick('middleName'); // Shows appropriate type error

const JustFirst = Person.pick('firstName')
JustFirst.pick('firstName'); // Not allowed?!? Error: `Argument of type 'string' is not assignable to parameter of type 'never'.`

const ExtraPerson = Person.set('middleName', 'Mikhailovich');
ExtraPerson.pick('middleName', 'butNothingIsTyped'); // Typing is expanded to any `Record<string, any>`, so no errors

See this TS Playground. Is there a way to have the generic PropType essentially re-initialized/extended with each initialization of the parent class?

EDIT:

I've figured out a solution to set in particular, which I think works:

  set<K extends string, V extends unknown>(key: K, value: V) {
    return new Schema<PropType & Record<K, V>>({ ...this.props as PropType, [key]: value });
  }
1 Answers

Figured it out with some help from @Etheryte! Example below, but here's the link to the playground.

class Schema<PropType extends Record<string, unknown>> {
  props: Partial<PropType> = {};
  constructor(props: PropType) {
    this.props = props as PropType;
  }

  pick<K extends keyof PropType>(...propNames: Array<K>) {
    return new Schema<Record<K, unknown>>(propNames.reduce(
      (obj: any, key: K) => ({ ...obj, [key]: this.props[key] }),
      {}
    ));
  }

  omit<K extends keyof PropType>(...propNames: Array<K>) {
    const toOmit: Partial<PropType> = propNames.reduce((obj, k) => ({ ...obj, [k]: true }), {});
    type NewKey = keyof Omit<PropType, K>
    return new Schema<Record<NewKey, unknown>>(Object.keys(this.props).reduce(
      (obj: any, key: keyof PropType) => toOmit[key] ? obj : ({ ...obj, [key]: this.props[key] }),
      {}
    ));
  }

  set<K extends string, V extends unknown>(key: K, value: V) {
    return new Schema<PropType & Record<K, V>>({ ...this.props as PropType, [key]: value });
  }
}

const Person = new Schema({ firstName: 'Fyodor', lastName: 'Dostoevsky' });
Person.pick('firstName');

const JustFirst = Person.pick('firstName')
JustFirst.pick('firstName'); // Allowed
JustFirst.pick('firstName', 'lastName'); // Not allowed

const JustFirst2 = Person.omit('lastName');
JustFirst2.pick("firstName") // Works
JustFirst2.pick("lastName"); // Doesn'!

const All = Person.pick("firstName", "lastName");
const ExtraPerson = All.set('middleName', 'Mikhailovich');
ExtraPerson.pick('middleName', 'notAcceptable', "lastName"); // Only typed keys are allowed, so middleName and lastName are good, but notAcceptable is not
Related