Reputation: 3283
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:
public extend<T extends DecoratorAbstract>(decoratorName: { new (): T }) {
let decorator = new decoratorName();
decorator.parent = this;
return decorator;
}
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;
}
}
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.
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).
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.
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:
A
must be aware of the type of B
, otherwise it will use the type it extends 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
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
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