Skip to content

Instantly share code, notes, and snippets.

@HerringtonDarkholme
Last active March 22, 2019 17:17
Show Gist options
  • Save HerringtonDarkholme/f11152e6e29145040fa4 to your computer and use it in GitHub Desktop.
Save HerringtonDarkholme/f11152e6e29145040fa4 to your computer and use it in GitHub Desktop.
typesafe DI in TypeScript
/// <reference path='./typings/tsd.d.ts' />
import 'reflect-metadata'
type _ = {}
type ClassN<N, T> = { new (...a: N[]): T }
type FN8<A, B, C, D, E, F, G, H, R> = (a?: A, b?: B, c?: C, d?: D, e?: E, f?: F, g?: G, h?: H) => R
type CLS8<A, B, C, D, E, F, G, H, R> = { new (a?: A, b?: B, c?: C, d?: D, e?: E, f?: F, g?: G, h?: H): R}
class Token<T> {
private _tokenBrand
constructor(public name: string) {}
}
type Bindable<T> = Token<T> | ClassN<_, T>
const PARAMTYPE = 'design:paramtypes'
function getSignature<T>(cls: ClassN<T, _>): Array<Bindable<T>> {
return Reflect.getOwnMetadata(PARAMTYPE, cls).slice() || []
}
const enum BindType { CLASS, VALUE, FACTORY }
class Binder<T, Base> {
dependencies: Bindable<Base>[] = []
cacheable: boolean = false
private _type: BindType
private _cls: ClassN<any, T> = null
private _val: T
private _fn: Function
constructor(private _injector: Injector<Base>) {}
private _releaseInjector() {
let injector = this._injector
this._injector = null
return injector
}
toClass<A extends Base, B extends Base, C extends Base, D extends Base, E extends Base, F extends Base, G extends Base, H extends Base>(fn: CLS8<A, B, C, D, E, F, G, H, T>, a?: Bindable<A>, b?: Bindable<B>, c?: Bindable<C>, d?: Bindable<D>, e?: Bindable<E>, f?: Bindable<F>, g?: Bindable<G>, h?: Bindable<H>): Injector<Base | T>
toClass(cls: ClassN<Base, T>, ...deps: Bindable<Base>[]): Injector<Base | T> {
this._type = BindType.CLASS
this._cls = cls
this.dependencies = getSignature(cls)
deps.forEach((d, i) => {
if (d) this.dependencies[i] = d
})
return this._releaseInjector()
}
toValue(val: T): Injector<Base | T> {
this._type = BindType.VALUE
this._val = val
this.cacheable = true
return this._releaseInjector()
}
toFactory<A extends Base, B extends Base, C extends Base, D extends Base, E extends Base, F extends Base, G extends Base, H extends Base>(fn: FN8<A, B, C, D, E, F, G, H, T>, a?: Bindable<A>, b?: Bindable<B>, c?: Bindable<C>, d?: Bindable<D>, e?: Bindable<E>, f?: Bindable<F>, g?: Bindable<G>, h?: Bindable<H>): Injector<Base | T>
toFactory(fn: (...a: Base[]) => T, ...deps: Bindable<Base>[]): Injector<Base | T> {
this._type = BindType.FACTORY
this._fn = fn
this.dependencies = deps
return this._releaseInjector()
}
resolve(deps: any[]): Promise<T> {
switch (this._type) {
case BindType.CLASS:
let cls = this._cls
return Promise.resolve(new cls(...deps))
case BindType.VALUE:
return Promise.resolve(this._val)
case BindType.FACTORY:
return Promise.resolve(this._fn(...deps))
}
}
}
export class Injector<Base> {
private _resolving = new Set<Bindable<_>>()
private _typeBinderMap = new Map<Bindable<_>, Binder<_, Base>>()
private _cache = new Map<Bindable<_>, Promise<any>>()
get<T extends Base>(cls: Bindable<T>): Promise<T> {
let binder = this._typeBinderMap.get(cls)
if (!binder) throw new NoBinding(cls['name'])
let cache = binder.cacheable ? this._cache.get(cls) : null
if (cache) {
return cache
}
if (this._resolving.has(cls)) throw new CyclicDependency(cls['name'])
this._resolving.add(cls)
let depPromise = binder.dependencies.map(dep => this.get(dep))
this._resolving.delete(cls)
let ret = Promise.all(depPromise).then(args => binder.resolve(args))
if (binder.cacheable) this._cache.set(cls, ret)
return ret
}
bind<T>(cls: Bindable<T>): Binder<T, Base> {
let binder = new Binder<T, Base>(this)
this._typeBinderMap.set(cls, binder)
return binder
}
bindWithCache<T>(cls: Bindable<T>): Binder<T, Base> {
let binder = this.bind<T>(cls)
binder.cacheable = true
return binder
}
use<A>( a: ClassN<Base, A>): Injector<A|Base>{
return this.bind(a).toClass(a)
}
useWithCache<A>( a: ClassN<Base, A>): Injector<A|Base>{
return this.bindWithCache(a).toClass(a)
}
static create(): Injector<Injector<_>> {
let inj = new Injector<Injector<_>>()
inj.bind(Injector).toValue(inj)
return inj
}
static inherit<A>(p: Injector<A>): Injector<A> {
let inj = new Injector<A>()
p._typeBinderMap.forEach((val, key) => {
inj._typeBinderMap.set(key, val)
})
return inj
}
}
export class CyclicDependency extends Error {
constructor(tokenName: string) {
super(`Cyclic Dependency: ${tokenName}`)
}
}
export class NoBinding extends Error {
constructor(tokenName: string) {
super(`No Binding Provided: ${tokenName}`)
}
}
export function token<T>(name: string): Token<T> {
return new Token(name)
}
export function Inject(a: any): void {}
/// <reference path='../typings/tsd.d.ts' />
import {Inject, Injector, token, NoBinding, CyclicDependency} from '../index'
import * as assert from 'assert'
@Inject
class Customer {
private brand
constructor() {}
}
@Inject
class Clerk {
private brand
constructor() {}
}
@Inject
class Shop {
private shopBrand
constructor(public c: Clerk) {}
static test(a: number) {}
}
class B {
}
@Inject
class A {
constructor(b: B) {}
}
var clerkToken = token<Clerk>('clerk')
describe('di', () => {
it('should compile', done => {
var injector = Injector.create()
var b = injector
.bind(clerkToken).toClass(Clerk)
.bind(Shop).toClass(Shop, clerkToken)
.get(Shop)
b.then(s => {
assert(s instanceof Shop)
assert(s.c instanceof Clerk)
}).then(done)
})
it('should throw unresolved token', () => {
var injector = Injector.create()
assert.throws(_ => {
var b = injector
.use(Customer)
.bind(clerkToken).toClass(Clerk)
.bind(Shop).toClass(Shop)
.get(Shop)
}, NoBinding)
})
it('should throw cyclic error', () => {
var injector = Injector.create()
assert.throws(_ => {
var b = injector
.use(A)
.bind(B).toFactory((a) => new B(), A)
.get(A)
}, CyclicDependency)
})
it('should return new instance', done => {
var injector = Injector.create()
var b = injector
.use(Customer)
Promise.all([b.get(Customer), b.get(Customer)]).then(([a,b]) => {
assert(a instanceof Customer)
assert(b instanceof Customer)
assert(a !== b)
})
.then(done)
})
it('should return same instance when cached', done => {
var injector = Injector.create()
var b = injector
.useWithCache(Customer)
Promise.all([b.get(Customer), b.get(Customer)]).then(([a,b]) => {
assert(a instanceof Customer)
assert(b instanceof Customer)
assert(a === b)
})
.then(done)
})
it('should return cached instance in deep', done => {
var injector = Injector.create()
var b = injector
.useWithCache(Clerk)
.use(Shop)
Promise.all([b.get(Shop), b.get(Shop)]).then(([a,b]) => {
assert(a instanceof Shop)
assert(b instanceof Shop)
assert(a !== b)
assert(a.c === b.c)
})
.then(done)
})
it('should inherit parent', done => {
var injector = Injector.create()
var a = injector
.use(Customer)
.use(Clerk)
.use(Shop)
var b = Injector.inherit(a)
b.get(Customer).then(a => {
assert(a instanceof Customer)
})
.then(done)
})
it('should override parent', done => {
var injector = Injector.create()
var a = injector
.use(Customer)
.use(Clerk)
.use(Shop)
var b = Injector.inherit(a)
var clerk = new Clerk
b.bind(Clerk).toValue(clerk)
b.get(Shop).then(a => {
assert(a instanceof Shop)
assert(a.c === clerk)
})
.then(done)
})
it('should use token', done => {
var injector = Injector.create()
var tkn = token<number>('test')
var b = injector
.bind(tkn).toValue(456)
b.get(tkn).then(a => {
assert(a === 456)
})
.then(done)
})
})
{
"version": "v4",
"repo": "borisyankov/DefinitelyTyped",
"ref": "master",
"path": "typings",
"bundle": "typings/tsd.d.ts",
"installed": {
"es6-collections/es6-collections.d.ts": {
"commit": "3d2f9971a107e2eee2de3ec5318f5c54071e16ed"
},
"es6-promise/es6-promise.d.ts": {
"commit": "3d2f9971a107e2eee2de3ec5318f5c54071e16ed"
},
"mocha/mocha.d.ts": {
"commit": "6d3c9f422fe385f4f34d3be4c7920476163c87ec"
},
"node/node.d.ts": {
"commit": "6d3c9f422fe385f4f34d3be4c7920476163c87ec"
}
}
}
@HerringtonDarkholme
Copy link
Author

But TS' type system does not allow a full-fledged DI in this way.

  1. First, runtime types are erased. One must annotate dependency for function in toFactory method. toClass is better because TS supports emitDecoratorMetadata. (maybe resolved in TS2.0). TS' specific metadata implementation is also problematic. For cyclic dependent classes, at least one class' annotation is undefined(ES3/5), or the script is crashed before it can run (ES6). Because metadata is attached to class declaration, in cyclic case there must be one class is used before it's declared.
  2. But even metadata is not enough. Runtime type data is first-order (in type-system's view), that is, every type is represented by its constructor, no generic information is emitted. To work around this, token is introduced.

Token alleviates runtime type-system's weakness, and enables binding multiple implementations to one single type. It also introduces more problem this DI wants to resolve in the first place. To work around point 1, we attached runtime types to constructor. Binding token will make type system think a type has resolved, but a following binding may not resolve it in runtime because it depends on constructor to find resolution.

injector
  .bind(clerkToken).toClass(Clerk)
  .bind(Shop).toClass(Shop) // compiles. but runtime error
// toClass will analyze Shop's signature and extract the Clerk constructor
// it can be found in type-level because Token<Clerk> enable injector to resolve Clerk, but at runtime injector can only resolve clerkToken, not Clerk

Also, tokens with same types cannot avoid this.

The workaround is, well, abusing string literal type. So every token is different at type-level. This requires users to type more types, and casts string literal from string type to string literal type. (TS's generic inference does not have something like T extends StringLiteral so that T is inferred as string literal type)

Also, the toClass and toFactory signature should differentiate what can be resolved by constructor and by token. This is technically possible, just override these signature to support distinguishing between token and constructor. But the number of resulting overriding is exponential to the number of argument. 2 ^ n, where n is the number of arguments.

@HerringtonDarkholme
Copy link
Author

To fully support type-safe DI, a compiler extension or a code generator is needed. Java's DI relies on annotations and code generation.
Maybe Babel can do this right now. But TypeScript still needs a long way to go for a customizable emitter.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment