2

Here's a class that is constructed to augment a data object with methods.

export default class ViewModel<T> {
  constructor(props: T) {
    Object.assign(this, props);
  }

  foo() {
    console.log('This class and children have methods as well as data');
  }
}

Is it possible to have TypeScript understand that all the props of T exist on the instances of ViewModel<T>?

type User = { id: number; name: string };

const m = new ViewModel<User>({ id: 1, name: 'Alice' });

m.foo(); // fine

console.log(m.id); // Property 'id' does not exist

(Playground)

If not, is there a better way to wrap a plain JS object with methods that access its data properties? I'm not attached to this abstract class and would be fine with UserViewModel in the above case, but the codebase already has many different T types and I'd prefer to re-use them if possible. (E.g. make UserViewModel reference User instead of duplicating much of that type's definition.)

I know I can also export an interface along with the class, like in Extending `this` in Typescript class by Object.assign , but I'd also like the methods in the class to know what values are available on this.

2
  • 1
    Is there any reason you can't allow the view model to have props? That makes the solution as simple as m.props.id. Commented Jul 20, 2019 at 0:10
  • This is a pre-existing design so that would require a large refactoring of existing code. Though additionally, from a design standpoint it's undesirable to require .props everywhere to hack around the type system. Commented Jul 21, 2019 at 18:05

1 Answer 1

4

TypeScript really doesn't like to let you use class if the type you are extending or implementing doesn't have statically-known members. A generic type like T doesn't work:

class Foo<T> implements T {} // error! 
// A class can only implement an object type or intersection of 
// object types with statically known members.

Typescript can represent such class constructors in the type system:

type BarConstructor = new <T>(props: T) => T & { foo(): void }; // okay

and if you had an object of that type you could use it the way you want to use ViewModel:

function makeABarAndDoThings(Bar: BarConstructor) {
  const bar = new Bar({ a: 1, b: "2", c: true });
  bar.foo(); // okay
  bar.a; // okay
  bar.b; // okay
  bar.c; // okay
}

but you can't use class to build a constructor that the compiler understands to be of that sort of generic type.


Note that when you write class Foo {}, you are introducing both a named type called Foo, corresponding to an instance of the class, and a named value called Foo, the constructor of the class.

Since we can't use your ViewModel class implementation directly, we will rename it out of the way, and then use type assertions and type aliases to create a type and a value named ViewModel which does behave as you desire.

Here's the renamed class:

class _ViewModel<T> {
  constructor(props: T) {
    Object.assign(this, props);
  }
  foo() {
    console.log("This class and children have methods as well as data");
  }
}

And here are the new type-and-value combo:

type ViewModel<T> = _ViewModel<T> & T;
const ViewModel = _ViewModel as new <T>(props: T) => ViewModel<T>;

The type ViewModel<T> is defined as the intersection of your _ViewModel<T> with T itself. And the new ViewModel value is the same as _ViewModel at runtime, but at compile time we've asserted that it is a generic constructor which takes a parameter of type T and produces a ViewModel<T>.


Let's see how it works:

type User = { id: number; name: string };

const m = new ViewModel<User>({ id: 1, name: "Alice" });

m.foo(); // fine
console.log(m.id); // also fine

Looks good to me now. Hope that helps; good luck!

Link to code

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.