0

I have a class where I want to chain certain operations. The class look something like this

class MyClass<T> {
   create<X>(fn: (_: T) => X): MyClass<X> {
       ...
       doCreate(fn);
       return this as unknown as MyClass<X>;
   }

   update<O>(x: (_: T) => O): MyClass<O> {
       ...
       doUpdate(x);
       return this as unknown as MyClass<O>;
   }

   validate(x: (_: T) => boolean): MyClass<T> {
      ...
      doValidation(x);
      return this;
   }

   log(str: string): MyClass<T> {
      doLog(str);
      return this;
   }
}

Let's take an example of end-to-end testing where there are multiple operations required to perform a test. And each step needs some sort of validation or logging. Those tests can get quite messy soon.

Imagine having a simple high-level API that does these forms of operations sequentially, like,

  • create operation
  • performing validations on the result from previous step and passing it forward
  • transforming the output from the previous operation to be used in the next create operation
  • performing another create operation from the previous step
  • validation the output of previous step
  • Finishing the sequence

Code representation could be something like the following,

abc.create((x) => "3")
  .validate((x) => x instanceof string)
  .log("Created a string")
  .update(parseInt)
  .log("Updated to integer")
  .log("Using the number updated in the previous call as an input to create")
  .create((x) => x * 3)
  .validate((x) => x instanceof number)
  .log("Multplying the number by 3")
  .update((x) => x + "3")
  .done();

I can not achieve the behavior without force casting the instance to another type which I'm doing with the following statement in create and update methods.

return this as unknown as MyClass<O>;

Is it a bad practice to force cast the initial type to something else like done above? If so, what would be a better way to achieve the same? Probably, creating a new instance of the class and returning it could be a solution but it includes the overhead of a new object being created.

6
  • I'm not clear on what this achieves. you have functions that converts type A to type B etc and you want to force people to pass them into a wrapper class rather than use them directly? What if I have a conversion which isnt supported with a matching method in MyClass? Why would I use MyClass instead of just the functions im passing in?
    – Ewan
    Mar 6 at 11:38
  • @Ewan I might have not included the details of what those methods do. But there are other statements in the methods that modify the state of the class in some way. The conversion will always be supported as the return type is defined by the function that the caller is passing to the methods. I'm just trying to build some high-level API which in some way improves readability for a flow-based system, where the consumer of the API can call the methods any number of times. There are other methods in the class but I've kept just two here.
    – pratZ
    Mar 6 at 12:29
  • 1
    Any "is this a good way to do X?" question boils down to "do you know a better way to achieve X?" You need to specify the full requirements, otherwise its trivial to say that the better way is just to use the function you would otherwise pass in.
    – Ewan
    Mar 6 at 12:34
  • Let's take an example of end-to-end testing where there are multiple operations required to perform a test. And each step needs some sort of validation. Those tests can get quite messy soon. Imaging having a simple high-level api which does these forms of operations, like, perform operation -> validate -> transform the output from the previous operation to be used in next operation -> perform another operatino -> validate -> finsh
    – pratZ
    Mar 6 at 12:42
  • @DocBrown I have added some more details to the post. Hope that clarifies the problem a bit.
    – pratZ
    Mar 6 at 15:21

1 Answer 1

6

Changing the type of an existing object is simply not something that can be done in a typesafe manner. The object might be referenced in one place as a MyClass<A> and in another place as a MyClass<B>, with no relationship between types A and B. This can lead to contradictions, for example:

class Transmutation<T> {
  constructor(public value: T) {}

  transmute<OtherType>(newValue: OtherType): Transmutation<OtherType> {
    (this.value as unknown) = newValue;
    return this as unknown as Transmutation<OtherType>;
  }
}

const t1: Transmutation<number> = new Transmutation(123);
const t2: Transmutation<string> = t1.transmute<string>("foo");
const value: number = t1.value;  // has type number, but is actually string "foo"

In contrast, changing the type may be legal if the generic type is variant with respect to this type parameter. For example, an object with actual type ReadonlyList<SubClass> can be safely referenced as a ReadonlyList<BaseClass>.

In most cases, the solution would be to return a new object with a new type instead of changing the type of this. This might be less efficient, but this looks like a programming pattern that should be easy to handle for a garbage collector (for example, by actually reusing the storage of the old object, as you wanted to do directly). Then, your methods would look more like:

create<X>(fn: (_: T) => X): MyClass<X> {
   const x = doSomething(fn, this.value);
   return new MyClass<X>(x);
}

Some type systems are conceptually able to safely change the type of an object since they can guarantee that the old version of the object is no longer referenced (“affine” type systems that allow each value to be used at most once). TypeScript does not have such a type system.

3
  • Thanks for the detailed explanation. I think I get the point, even though I will be able to convert the types but doing that breaks the type safety. So, creating a new instance probably would be the way to go.
    – pratZ
    Mar 6 at 15:49
  • not sure you're correct about it being impossible. Say the transmutation class consists of some data held in a string and a function that converts that string to the required type. I can convert it endlessly as long as each time I supply an extra conversion function which combines with the existing function.
    – Ewan
    Mar 7 at 10:44
  • 1
    @Ewan I'm like 80% sure that the combination of aliasing + mutability necessarily means that changing the type of an existing object is inherently unsafe, unless constrained via appropriate variance rules. For example, assume a stream of values where the interface only allows us to consume elements. Then, a Stream<BaseClass> can safely change into a Stream<SubClass>. I think your suggested approach with an extra conversion function does not afford such safety, but I can only make a solid argument if you provide some pseudocode of your approach.
    – amon
    Mar 7 at 10:56

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.