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

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