A coworker shared a TypeScript snippet. Here's my thoughts.
Here's the [paraphrased] message:
Something I thought I'd post here... one of the most useful custom types I've seen and used:type ClassName<T> = { new (...args: Array<any>): T }Here's example:
class HttpNotification { } class SystemNotification { } type Clazz<T> = { new (...args: Array<any>): T; } interface INotification<T> { type: Clazz<T>; message: string; } const subject = new Subject<INotification<object>>(); subject.asObservable().filter((n: Notification<object>) => n.type.name === HttpNotification.name) subject.next({ type: HttpNotification, message: 'Hello World' }); subject.next({ type: SystemNotification, message: 'Wont Be Emitted' });
The remainder of this document is my thoughts ramblings on the pattern.
Let's start by pairing down the example to focus only on the demonstrative parts:
class MsgFoo { }
class MsgBar { }
type Clazz<T> = { new (...args: any[]): T }
interface IMsgWrapped {
type: Clazz<object>,
message: string
}
const predicateFn = (msgWrapped: IMsgWrapped) => {
return msgWrapped.type.name === MsgFoo.name
}
Let's begin with the arg msgWrapped
applied to the predicateFn
(specifically msgWrapped#type#name
). Where does the descriptor type#name
come from?
An ES6 specification exists for function objects
(ECMA Language Spec) to add a name
property. While most browsers exhibit these semantics, it is worth noting that the association is made by the interpreter at runtime! (MDN)
One may have noticed not having autocomplete on the dangling type
property. How do we fix that?
Well, classes in ES6 are Functions (really only syntactic sugar over normal prototype-based inheritance.) This is also how the TypeScript compiler crunches a simple class definition (TS Playground):
class Greeter {
greeting: string;
constructor(message: string) {
this.greeting = message;
}
greet() {
return "Hello, " + this.greeting;
}
}
compiles to
var Greeter = /** @class */ (function () {
function Greeter(message) {
this.greeting = message;
}
Greeter.prototype.greet = function () {
return "Hello, " + this.greeting;
};
return Greeter;
}());
Returning to our minimalistic example, and observing that our classes FooMsg
and BarMsg
are just Function
s, we can restore the benefits of intellisense, discriminated unions, etc in our editing environment by changing the Generic type from to object
Function
:
interface IMsgWrapped {
type: Clazz<Function>,
message: string
}
OK. So the resolution of name
and why our code doesn't break at run-time should be clear now, but is this a good idea? The generic class Clazz<T>
does have legitimate use cases (e.g.; _Factory pattern` creation TS Docs) but the context in which it is used here is still suspect to me.
Since there are no method overloads (thankfully) in JS (or TS):
// No method overloads!
class Reporter {
handleMsg(msg: FooMsg) { console.log(msg); }
handleMsg(msg: BarMsg) { doSomethingElse(); }
}
we use other means to determine characteristics of the class/object in question. There's a few User-Defined Type Guard techniques we may employ.
There's good ol' instanceof
branching (TS Docs), but that's less than ideal if your class heirarchy is not flat.
There's also user-defined type guards we could write, like
const isFooMsg = (msg: Message): msg is FooMsg => {
return (<FooMsg>msg).propOrMethod !== undefined;
};
Neither of which seem to fit our needs cleanly though since (in our context) the behaviors and structure of our classes are identical.
Hold on this this fact for a moment... we'll come back to it.
Especially when we can do it ourselves with Enums
, or Symbols
. Or even strings
if that suits you (its what the interpreter is doing):
const FOO_MSG = Symbol('FOO_MSG');
const BAR_MSG = Symbol('BAR_MSG');
class FooMsg { get kind() { return FOO_MSG } }
class BarMsg { get kind() { return BAR_MSG } }
after some refactoring:
const FOO_MSG = Symbol('FOO_MSG');
const BAR_MSG = Symbol('BAR_MSG');
interface ITypedMessage { kind: Symbol; }
abstract class AbsMsg implements ITypedMessage {
protected abstract _kind = Symbol('abstract');
get kind() { return this._kind };
}
class FooMsg extends AbsMsg { protected _kind = FOO_MSG; }
class BarMsg extends AbsMsg { protected _kind = BAR_MSG; }
But it still feels kind of wonkey to me... Can we solve this problem and the Type-Sniffing issue at once?
Because the classes have no "behavior" and their structural interfaces are identical, I'd propose just adding a self-identifying "type" descriptor. This is not unlike a flux standard action type
or RxJS's Notification class. You'll also see this scheme in use in Message Queues routing (e.g.; routing keys, named exchanges, content-based routing, ...) The context offered up in the shared snippet certainly looks like pipes-and-filters
style :)
The whole minimal example can be simplified to:
interface IMsg {
kind: 'FOO' | 'BAR'; // <== or union type if many kinds
message: string;
}
const predicateFn = (msg: IMsg) => 'FOO' === msg.kind;
Note strings-based identifiers have the potential benefit of serialization.
or expanding to the original context:
import { Observable } from '@reactivex/rxjs';
enum MESSAGE { FOO = 'FOO', BAR = 'BAR' }
interface IMsg {
kind: MESSAGE;
message: string;
}
const isFoo = (msg: IMsg) => MESSAGE.FOO === msg.kind;
const msgs$: Observable<IMsg> = Observable.of(
{ kind: MESSAGE.FOO, message: 'Hi' },
{ kind: MESSAGE.BAR, message: 'Skipped' },
{ kind: MESSAGE.FOO, message: 'Hi' }
)
msgs$.filter(isFoo).subscribe(msg => console.log(msg));