arvymetal
arvymetal

Reputation: 3283

Typescript typings in chained calls

UPDATE:

My issue

I'm trying to write some pattern that would allow an object to extend another object without class inheritance, to achieve loose coupling and a functional approach (what I represent by the "extend" function in my sample, which could also be called "pipe"). Which is easy. But all of that while preserving the typings. Which is hard.

I'm wondering how to get Typescript typings follow the flow.

Here is a simplified code segment, for the example (link for Typescript playground):

abstract class DecoratorAbstract {
    public parent: DecoratorAbstract;

    public extend<T extends DecoratorAbstract>(decorator: T): T {
        decorator.parent = this;
        return decorator;
    }

    abstract decorate(): {}
}

class InitialDecorator extends DecoratorAbstract {
    decorate() {
        return { var: 'value' } as { 'var': string };
    }
}

class Decorator1 extends DecoratorAbstract {
    decorate() {
        let result = this.parent.decorate();
        let newResult = Object.assign(result, { 'foo': 'foo' });
        return newResult;
    }
}

class Decorator2 extends DecoratorAbstract {
    decorate() {
        let result = this.parent.decorate();
        let newResult = Object.assign(result, { 'bar': 'bar' });
        return newResult;
    }
}

// Split to show the return types 
let initialDecorator = new InitialDecorator();
let r0 = initialDecorator.decorate();

let decorator1 = initialDecorator.extend(new Decorator1);
let r1 = decorator1.decorate();

let decorator2 = decorator1.extend(new Decorator2);
let r2 = decorator2.decorate();

console.log(r2);

// The syntax I seek
let someDecorator = new InitialDecorator()
    .extend(new Decorator1);
    .extend(new Decorator2);

let someResult = someDecorator.decorate();
// The type I seek:
// {var: string} & {foo: string} & {bar: string}

The typings I get: {} & {'foo': string;} for r1 and {} & {'bar': string;}for r2, replacing the returned type of decorator.parent.decorate by {} (result of the abstract function) instead of the real type.

So I'm wondering:

  1. Is what I try to do possible?
  2. If not, why?
  3. If yes, is there a solution something not syntactically too horrible (and following that concept or something close)

UPDATE: The things I tried

With a factory

public extend<T extends DecoratorAbstract>(decoratorName: { new (): T }) {
   let decorator = new decoratorName();
   decorator.parent = this;
   return decorator;
}

To use some Piper object

class Piper<T extends {parent: PARENT}, PARENT> {
    public obj: T;
    public parent: PARENT;

    constructor(obj: T, parent: PARENT) {
        this.obj = obj;
        this.obj.parent = this.parent as PARENT;
    }

    out(): T {
        return this.obj;
    }
}

To use a collection

But my collection objects type has to be known at the beginning and the same for all the items. Maybe I didn't do it the right way.

Linked List approach

That is working:

class Link<OBJ, PARENTLINK> {
    constructor(public obj: OBJ, public parent:PARENTLINK = null) {}

    pipe<NEWOBJ>(newObj: NEWOBJ):Link<NEWOBJ, this> {
        return new Link(newObj, this);
    }
}

abstract class DecoratorAbstract {
    abstract decorate<T>(result: T): {}
}

class InitialDecorator extends DecoratorAbstract {
    decorate<T>(result:T) {
        return { var: 'value' } as { 'var': string };
    }
}

class Decorator1 extends DecoratorAbstract {
    decorate<T>(result:T) {
        let newResult = Object.assign(result, { 'foo': 'foo' });
        return newResult;
    }
}

class Decorator2 extends DecoratorAbstract {
    decorate<T>(result:T) {
        let newResult = Object.assign(result, { 'bar': 'bar' });
        return newResult;
    }
}

let l = new Link(new InitialDecorator);
let l1 = l.pipe(new Decorator1);
let l2 = l1.pipe(new Decorator2);

I can create a chain of objects and preserve their types. But it doesn't help so much... As soon as the this word is encountered, the typings don't follow anymore (fall back on the Abstract class).

So far...

I didn't succeed to make Typescript retain my object type yet. Mainly because:

I think my issue could be solved by a way to design a Linked List or a Collection that would preserve the typings of each element.


UPDATE: reduction of the issue to an injection problem

I think my attempts were close to a solution...

But the problem I always fall back on is the following (test it in Typescript playground):

abstract class BaseObj<P> { 
    parent: P
    abstract test(): {}
}

class A<P extends BaseObj<any>> extends BaseObj<P> {
    test() { 
        return this.parent.test();
    }   
}

class B<P extends BaseObj<any>> extends BaseObj<P> {
    test() {
        return {hop: 'la'};
    }
}

// let c = new A();  // Won't work 
let c = new A<B<any>>();
c.parent = new B;
c.parent.test(); // return type: {hop: string}

The problem is simple: I'd want the object A to call the method of an injected object B (in the following case, the injected object is embedded).

For that to be feasible in a general way:

It works in this example, because everything is very concrete, but instead of:

let c = new A<B<any>>();
c.parent = new B;

what I want is some function to allow injecting the class/object, this way:

pipe(MyObj, new InjectedObj()); or let a = pipe(new MyObj(), new InjectedObj());

The issue is that I didn't manage to return out of the pipe function an instance of A that is typed as A<B<any>>, always A<any>...

Detecting the types in entry of pipe is not an issue. But getting an object aware of the other (injected) object type is where I always fail...

Whatever the way I write it, fall back on a problem apparented to this one.


My attempts:

Upvotes: 1

Views: 601

Answers (2)

Rodris
Rodris

Reputation: 2858

TypeScript can't know the return type A<B<any>> if it is not defined anywhere. If you want to mount the pipes at runtime, it will only know its type at runtime.

How about to make it defined as the final return, like A<string>? It could still call B, but, at the end, what matters is that the final result will be a string.

type PipeConstructor<TOut, TIn> = { new(): Pipe<TOut, TIn> };

abstract class PipeEnd<TOut> {
    abstract test(): TOut;
}

abstract class Pipe<TOut, TIn> extends PipeEnd<TOut> {
    parent: PipeEnd<TIn>;

    static mount<TOut extends T, T>(...pipes: PipeConstructor<T, T>[]): PipeEnd<TOut> {
        let pipeEnd: Pipe<TOut, T>;
        let pipe: Pipe<T, T>;
        let pipePrevious: Pipe<T, T>;

        for (var pipeConstructor of pipes) {
            pipe = new pipeConstructor();
            if (!pipeEnd) pipeEnd = <Pipe<TOut, T>>pipe;
            if (pipePrevious) {
                pipePrevious.parent = pipe;
            }
            pipePrevious = pipe;
        }

        return pipeEnd;
    }
}

class A extends Pipe<string, string> {
    test() {
        return this.parent.test().toString();
    }
}

class B extends Pipe<string, number> {
    test() {
        return this.parent.test().toString();
    }
}

class C extends Pipe<number, void> {
    test() {
        return 123;
    }
}

let pipe = Pipe.mount<string, number | string | void>(A, B, C);

console.dir(pipe); // A.B.C
console.log(pipe.test()); // 123

Upvotes: 1

Robert Penner
Robert Penner

Reputation: 6418

It may be easier to manage the generics with a minimalist functional approach:

const decorate1 = <T>(item: T) => Object.assign({}, item, { b: 'b' })
const decorate2 = <T>(item: T) => Object.assign({}, item, { c: 'c' })
const multiDecorate = <T>(item: T) => decorate2(decorate1(item));

const initial = { a: 'a' };
const multiDecorated = multiDecorate(initial);

console.log({ multiDecorated });

Try it in TypeScript Playground

The type flows through so that multiDecorated is the intersection type:

{} & { a: string; } & { b: string; } & { c: string; }

Upvotes: 0

Related Questions