Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save eckdanny/25c67b150aa9b0fba660bbf8416c151e to your computer and use it in GitHub Desktop.
Save eckdanny/25c67b150aa9b0fba660bbf8416c151e to your computer and use it in GitHub Desktop.
on Class Type Generics and Duck Typing in TypeScript

A coworker shared a TypeScript snippet. Here's my thoughts.

The Post

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.

Minimal Example

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 Functions, we can restore the benefits of intellisense, discriminated unions, etc in our editing environment by changing the Generic type from object to 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.

Type-Sniffing?

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.

Why rely on the interpreter to associate your classes?

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?

Self-Identifying Messages

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));
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment