Friday, March 20, 2020

Angular Service

Simpler way to make Angular service available to component, just add providedIn: 'root'

@Injectable({providedIn: 'root'})
export class ProductService {


Then on consuming component:
@Component({
    selector: 'pm-products',
    templateUrl: './product-list.component.html'
    // no need to add provider
})
export class ProductListComponent {
    constructor(private _productService: ProductService)
}


To make it more explicit:

@Injectable()
export class ProductService {

Then on consuming component:
@Component({
    selector: 'pm-products',
    templateUrl: './product-list.component.html'
    providers: [ProductService] // explicitly add the service here
})
export class ProductListComponent {
    constructor(private _productService: ProductService)
}

Thursday, March 12, 2020

Use .filter? Sorry, no control flow analysis joy for you

function* getRoutesComponentsX(routes: Routes) {
    yield* routes
        .filter(route => route.component)
        .map(route => route.component);

    yield* routes
        .filter(route => route.children)
        .map(route => Array.from(getRoutesComponentsX(route.children!)))
        .reduce((a, b) => a.concat(b), []);
}

TypeScript won't be able to follow the flow of control when using .filter instead of if statement.

That would sometimes lead to use of non-null assertion operator.

Removing the non-null assertion operator would lead to this error:



Here's essentially same code, albeit using just if statements, no more need to use the non-null assertion operator.
function* getRoutesComponents(routes: Routes) {
    for (const route of routes) {
        if (route.component) {
            yield route.component;
        }

        if (route.children) {
            yield* getRoutesComponents(route.children);
        }
    }
}

Wednesday, March 11, 2020

Extracting components from Angular Routes

When I forgot to declare (in declarations) the components used in Angular Routes, it resulted to error: NG8002: Can't bind to 'ngModel' since it isn't a known property of 'input'


I decided to make a helper function:

function* getRoutesComponents(routes: Routes) {
    for (const route of routes) {
        if (route.component) {
            yield route.component;

            if (route.children) {
                yield* getRoutesComponents(route.children);
            }
        }
    }
}


Problem is, it gives these errors:




To enable runtime goodness, we need to disable AOT compilation, add --aot=false on angular commandline. However, disabling AOT would cause two major inconveniences.

First inconvenience, we cannot use constants as keys on objects (e.g., Angular Route) anymore. Doing so would result to example error: ERROR in Cannot read property 'loadChildren' of undefined




We can rectify the problem by avoiding the use of constants as keys:




Second inconvenience when AOT is disabled, despite fullTemplateTypeCheck is set to true, Angular won't be able to check name of the object(and properties) you are binding to ngModel if the name has a typo or misspellings. Angular will still happily build your project even if your template has full of typos or misspellings, which will result to runtime errors.



On the screenshot above, productNameBlahMeh does not exist on Product interface, yet Angular still build the project. This is due to AOT being disabled.


Those two inconveniences greatly outweighs the safety net for committing typos (or spelling mistakes). const can prevent typo errors as it enforces the single source of names in the code. It's hard to rely on developers not making typo or misspelling mistakes. fullTemplateTypeCheck only works if AOT is enabled.

Tuesday, March 10, 2020

NG8002: Can't bind to 'ngModel' since it isn't a known property of 'input'

This error will happen too even if the FormsModule is imported directly or indirectly (from shared module for example) in the feature module, if the imported component is not declared on declarations:



I followed Deborah Kurata's Angular Routing course, while I added the imported component ProductEditInfoComponent on Angular Route's component property, I forgot to add ProductEditInfoComponent on declarations property.

Adding the ProductEditInfoComponent on declarations property would solve the NG8002: Can't bind to 'ngModel' since it isn't a known property of 'input'. problem




Sunday, March 8, 2020

TypeScript non-null assertion operator

Use non-null assertion operator if and only if you can assert that it will not result to null


If the parameter name passed to paramMap.get has a typo, the paramMap.get will return null. How can the developer really assert that the expression would not return null if the developer can't guarantee that the code has no typo?
product-resolver.service.ts:
@Injectable({
    providedIn: "root"
})
export class ProductResolver implements Resolve<ProductResolved> {
    constructor(private productService: ProductService) {}

    resolve(
        route: ActivatedRouteSnapshot,
        state: RouterStateSnapshot
    ): Observable<ProductResolved> {
        const id = +route.paramMap.get('id')!;

        // A power user error, or QA-induced error
        if (isNaN(id)) {
            const error = `Product id was not a number: ${id}`;
            console.error(error);

            // false: cancel the route
            // null: continue, the component handle the null object
            // navigate to error page: no built-in resolver mechanism for passing error
            // messages out a route resolver to the prior or activated route.
            return of({ product: null, error });
        }

        return this.productService.getProduct(id).pipe(
            map(product => ({ product })),
            catchError(error => {
                const message = `Retrieval error: ${error}`;
                return of({ product: null, error });
            })
        );
    }
}


Solution:

Since the 'id' parameter on route.paramMap.get is a parameter on Angular Routes, introduce a const for 'id'. This way, making a typo would be impossible, then we can validly assert that it would be impossible to obtain null from paramMap.get since we can only use parameter names on paramMap.get that are present on Angular Routes.


product-resolver.params.ts:
export const PRODUCT_DETAIL_ID_PARAM = "id";
export const PRODUCT_RESOLVED = "productResolved";



product.module.ts:
import {
    PRODUCT_DETAIL_ID_PARAM,
    PRODUCT_RESOLVED
} from "./product-resolver.params";

const ROUTES: Routes = [
    { path: "products", component: ProductListComponent },
    {
        path: `products/:${PRODUCT_DETAIL_ID_PARAM}`,
        resolve: { [PRODUCT_RESOLVED]: ProductResolver },
        component: ProductDetailComponent
    },
    {
        path: `products/:${PRODUCT_DETAIL_ID_PARAM}/edit`,
        resolve: { [PRODUCT_RESOLVED]: ProductResolver },
        component: ProductEditComponent
    }
];

Code smell:
product-resolver.service.ts:
const id = +route.paramMap.get('id')!;


Using literal string (non-const) parameters on paramGet on non-null assertion expression would be a code smell as it is easy to make a typo when passing literal string, the expression will return null if there is a typo, thereby asserting non-null result is at best is weak. Consider any literal string as magic strings. Magic strings can be avoided by using const.

A developer using non-null assertion operator on expression that uses magic strings/numbers is a developer in hubris: "I never make typos in my entire life, promise!"


To remove that code smell on non-null assertion expression on paramMap.get, pass parameter name that was declared from const and Angular Routes:
product-resolver.service.ts:
const id = +route.paramMap.get(PRODUCT_DETAIL_ID_PARAM)!;

Sunday, November 24, 2019

Banana in a box for Angular 1.x code monkeys :)

Two-way databinding, used to be this simple in AngularJS 1.x:

<input ng-model="todo.completed" type="checkbox" />


Angular 2 made the two-way databinding explicit, which can be accomplished by either of the following:

<input [checked]="todo.completed" (input)="todo.completed = $event.target.checked" type="checkbox" />

<input [ngModel]="todo.completed" (ngModelChange)="todo.completed = $event" type="checkbox"/>


Still pining for the old days? You can do Angular 1's terser approach in Angular 2:
<input [(ngModel)]="todo.completed" type="checkbox" />

That's called banana in a box. Just put parenthesis (a.k.a. banana) around ngModel, otherwise it won't work.


It's true, we are code monkeys :)


Using the ngModel approach produces an error if FormsModule is not imported:

Can't bind to 'ngModel' since it isn't a known property of 'input'


To solve that, import FormsModule from "@angular/forms", a good place to import it is from app.module.ts, on entry point of the app.

Monday, October 14, 2019

Absolute import with Next.js and TypeScript

Make Next.js project TypeScript-capable by following this:

https://nextjs.org/blog/next-9#built-in-zero-config-typescript-support


Create shared directory in project’s root directory

Under shared directory, create randomizer.ts:

export function randomizer() {
    return 4;
}


Create deeply/nested directories under components directory

Under components/deeply/nested directory, create SampleComponent.tsx:

import React from "react";

// import { randomizer } from "../../../shared/randomizer";

import { randomizer } from "love/randomizer";

export function SampleComponent() {
    const a = randomizer();

    return (
        <div>
            <strong>I am strong {a}</strong>
        </div>
    );
}

Edit pages/index.tsx, then add SampleComponent, e.g.,

<p className="description">
    To get started, edit <code>pages/index.js</code> and save to
    reload.
</p>

<SampleComponent />


Add this import statement to pages/index.tsx:

import { SampleComponent } from "../components/deeply/nested/SampleComponent";

Edit tsconfig.json and add the following under compilerOptions:

"baseUrl": ".",
"paths": {
    "love/*": ["shared/*"]
}

Webpack must be informed of TypeScript’s paths, this can be done by modifying webpack’s resolve.alias object. To modify webpack’s configuration, create next.config.js in project’s root directory, then put the following:

const path = require("path");

module.exports = {
    webpack(config, { dev }) {
        config.resolve.alias = {
            ...config.resolve.alias,
            love: path.resolve(__dirname, "shared")
        };

        return config;
    }
};

Run yarn dev, everything shall work.


To avoid manual synchronization of tsconfig’s paths and webpack’s resolve.alias, use the following helper: (sourced from: https://gist.github.com/nerdyman/2f97b24ab826623bff9202750013f99e)

Create resolve-tsconfig-path-to-webpack-alias.js in project's root directory:

const { resolve } = require('path');

/**
 * Resolve tsconfig.json paths to Webpack aliases
 * @param  {string} tsconfigPath           - Path to tsconfig
 * @param  {string} webpackConfigBasePath  - Path from tsconfig to Webpack config to create absolute aliases
 * @return {object}                        - Webpack alias config
 */
function resolveTsconfigPathsToAlias({
    tsconfigPath = './tsconfig.json',
    webpackConfigBasePath = __dirname,
} = {}) {
    const { paths } = require(tsconfigPath).compilerOptions;

    const aliases = {};

    Object.keys(paths).forEach((item) => {
        const key = item.replace('/*', '');
        const value = resolve(webpackConfigBasePath, paths[item][0].replace('/*', '').replace('*', ''));

        aliases[key] = value;
    });

    return aliases;
}

module.exports = resolveTsconfigPathsToAlias;

Change next.config.js to following:

const path = require("path");

const resolveTsconfigPathsToAlias = require("./resolve-tsconfig-path-to-webpack-alias");

module.exports = {
    webpack(config, { dev }) {
        config.resolve.alias = {
            ...config.resolve.alias,
            ...resolveTsconfigPathsToAlias()
        };

        return config;
    }
};

Run yarn dev, everything shall work.


Sample working code: https://github.com/ienablemuch/experiment-react-nextjs-typescript-absolute-import