Inversion of control on naked TypeScript without pain

Hello, my name is Dmitry Karlovsky and (as far as I can remember) I am fighting with my environment. After all, it is so bone, oak, and never understands what I want from it. But at some point I realized that it was enough to endure it and something had to be changed. Therefore, now it is not the environment that dictates to me what I can and cannot do, but I dictate to the environment what it should be.





As you already understood, further we will talk about the inversion of control through the "environment context". Many people are already familiar with this approach from the "environment variables" - they are set when the program starts and are usually inherited for all programs that it launches. We will use this concept to organize our TypeScript code.





So what we want to get:





  • Functions, when called, inherit the context of the calling function.





  • Objects inherit context from their owner object





  • A system can have many context options at the same time





  • Changes in derived contexts do not affect the original





  • Changes in the original context are reflected in derivatives





  • Tests can run in isolated and non-isolated context





  • Boilerplate minimum





  • Maximum performance





  • The typecheck of it all





, - :





namespace $ {
    export let $user_name: string = 'Anonymous'
}

      
      



- . , :





namespace $ {
    export function $log( this: $, ... params: unknown[] ) {
        console.log( ... params )
    }
}

      
      



this



. , :





$log( 123 ) // Error

      
      



- . , :





$.$log( 123 ) // OK

      
      



, $



- , . :





namespace $ {
    export type $ = typeof $
}

      
      



this



, . , , :





namespace $ {
    export function $hello( this: $ ) {
        this.$log( 'Hello ' + this.$user_name )
    }
}

      
      



. , . , , , :





namespace $ {
    export function $ambient(
        this: $,
        over = {} as Partial< $ >,
    ): $ {
        const context = Object.create( this )
        for( const field of Object.getOwnPropertyNames( over ) ) {
            const descr = Object.getOwnPropertyDescriptor( over, field )!
            Object.defineProperty( context, field, descr )
        }
        return context
    }
}

      
      



Object.create



, , . Object.assign



, , , . , :





namespace $.test {
    export function $hello_greets_anon_by_default( this: $ ) {

        const logs = [] as unknown[]
        this.$log = logs.push.bind( logs )

        this.$hello()
        this.$assert( logs, [ 'Hello Anonymous' ] )

    }
}

      
      



, - $log



, . , , , , . :





namespace $ {
    export function $assert< Value >( a: Value, b: Value ) {

        const sa = JSON.stringify( a, null, '\t' )
        const sb = JSON.stringify( b, null, '\t' )

        if( sa === sb ) return
        throw new Error( `Not equal\n${sa}\n${sb}`)

    }
}

      
      



, $.$test



. , :





namespace $ {
    export async function $test_run( this: $ ) {

        for( const test of Object.values( this.$test ) ) {
            await test.call( this.$isolated() )
        }

        this.$log( 'All tests passed' )
    }
}

      
      



, . , , ( , , , , ..). , :





namespace $ {
    export function $isolated( this: $ ) {
        return this.$ambient({})
    }
}

      
      



$log



, - . , $isolated



, $log



:





namespace $ {
    const base = $isolated
    $.$isolated = function( this: $ ) {
        return base.call( this ).$ambient({
            $log: ()=> {}
        })
    }
}

      
      



, $log



.





, :





namespace $.test {
    export function $hello_greets_overrided_name( this: $ ) {

        const logs = [] as unknown[]
        this.$log = logs.push.bind( logs )

        const context = this.$ambient({ $user_name: 'Jin' })
        context.$hello()
        this.$hello()

        this.$assert( logs, [ 'Hello Jin', 'Hello Anonymous' ] )

    }
}

      
      



. :





namespace $ {
    export class $thing {
        constructor( private _$: $ ) {}
        get $() { return this._$ }
    }
}

      
      



. , . , . , , , :





namespace $ {
    export class $hello_card extends $thing {

        get $() {
            return super.$.$ambient({
                $user_name: super.$.$user_name + '!'
            })
        }

        get user_name() {
            return this.$.$user_name
        }
        set user_name( next: string ) {
            this.$.$user_name = next
        }

        run() {
            this.$.$hello()
        }

    }
}

      
      



, , :





namespace $.test {
    export function $hello_card_greets_anon_with_suffix( this: $ ) {

        const logs = [] as unknown[]
        this.$log = logs.push.bind( logs )

        const card = new $hello_card( this )
        card.run()

        this.$assert( logs, [ 'Hello Anonymous!' ] )

    }
}

      
      



, , . , , . , :





namespace $ {
    export class $hello_page extends $thing {

        get $() {
            return super.$.$ambient({
                $user_name: 'Jin'
            })
        }

        @ $mem
        get Card() {
            return new this.$.$hello_card( this.$ )
        }

        get user_name() {
            return this.Card.user_name
        }
        set user_name( next: string ) {
            this.Card.user_name = next
        }

        run() {
            this.Card.run()
        }

    }
}

      
      



. . $mem



. :





namespace $ {
    export function $mem(
        host: object,
        field: string,
        descr: PropertyDescriptor,
    ) {
        const store = new WeakMap< object, any >()

        return {
            ... descr,
            get() {

                let val = store.get( this )
                if( val !== undefined ) return val

                val = descr.get!.call( this )
                store.set( this, val )

                return val
            }
        }

    }
}

      
      



WeakMap



, . , , , :





namespace $.test {
    export function $hello_page_greets_overrided_name_with_suffix( this: $ ) {

        const logs = [] as unknown[]
        this.$log = logs.push.bind( logs )

        const page = new $hello_page( this )
        page.run()

        this.$assert( logs, [ 'Hello Jin!' ] )

    }
}

      
      



, . - . , , .





namespace $ {
    export class $app_card extends $.$hello_card {

        get $() {
            const form = this
            return super.$.$ambient({
                get $user_name() { return form.user_name },
                set $user_name( next: string ) { form.user_name = next }
            })
        }

        get user_name() {
            return super.$.$storage_local.getItem( 'user_name' ) ?? super.$.$user_name
        }
        set user_name( next: string ) {
            super.$.$storage_local.setItem( 'user_name', next )
        }

    }
}

      
      



- :





namespace $ {
    export const $storage_local: Storage = window.localStorage
}

      
      



, , , :





namespace $ {
    const base = $isolated
    $.$isolated = function( this: $ ) {

        const state = new Map< string, string >()
        return base.call( this ).$ambient({

            $storage_local: {
                getItem( key: string ){ return state.get( key ) ?? null },
                setItem( key: string, val: string ) { state.set( key, val ) },
                removeItem( key: string ) { state.delete( key ) },
                key( index: number ) { return [ ... state.keys() ][ index ] ?? null },
                get length() { return state.size },
                clear() { state.clear() },
            }

        })

    }
}

      
      



, , , $hello_card



$app_card



, .





namespace $ {
    export class $app extends $thing {

        get $() {
            return super.$.$ambient({
                $hello_card: $app_card,
            })
        }

        @ $mem
        get Hello() {
            return new this.$.$hello_page( this.$ )
        }

        get user_name() {
            return this.Hello.user_name
        }

        rename() {
            this.Hello.user_name = 'John'
        }

    }
}

      
      



, , , , , , , , :





namespace $.$test {
    export function $changable_user_name_in_object_tree( this: $ ) {

        const name_old = this.$storage_local.getItem( 'user_name' )
        this.$storage_local.removeItem( 'user_name' )

        const app1 = new $app( this )
        this.$assert( app1.user_name, 'Jin!' )

        app1.rename()
        this.$assert( app1.user_name, 'John' )

        const app2 = new $app( this )
        this.$assert( app2.user_name, 'John' )

        this.$storage_local.removeItem( 'user_name' )
        this.$assert( app2.user_name, 'Jin!' )

        if( name_old !== null ) {
            this.$storage_local.setItem( 'user_name', name_old )
        }

    }
}

      
      



, , . .





, , :





namespace $ {
    $.$test_run()
}

      
      



, , , . , $isolated



, - :





namespace $ {
    $.$ambient({
        $isolated: $.$ambient
    }).$test_run()
}

      
      



, , , localStorage, $storage_local



.





, , , , .





TechLeadConf: .





$mol, . …





c import/export, : Fully Qualified Names vs Imports. , : PascalCase vs camelCase vs kebab case vs snake_case.





TypeScript .








All Articles