Showing posts with label TypeScript. Show all posts
Showing posts with label TypeScript. Show all posts

Thursday, June 26, 2025

Strongly-typed MUI Form Helper

form.ts
import React from 'react';

type UnifiedChangeEvent =
  | React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
  | React.ChangeEvent<{ name?: string; value: unknown }>
  | (Event & { target: { name: string; value: unknown } });

export type ErrorObject<T> = Partial<Record<keyof T, string | boolean>>;

function handleInputChange<T>(
  values: T,
  setValues: (o: T) => void,
  fieldName: keyof T
) {
  return {
    onChange(event: UnifiedChangeEvent) {
      const { name, value } = event.target;
      setValues({ ...values, [name!]: value });
    },
    name: fieldName,
    value: values[fieldName],
  };
}

export function setupInputChange<T>(values: T, setValues: (o: T) => void) {
  return (fieldName: keyof T) =>
    handleInputChange(values, setValues, fieldName);
}

function handleErrorChange<T>(errors: T, fieldName: keyof T) {
  return errors[fieldName] && { error: true, helperText: errors[fieldName] };
}

export function setupErrorChange<T>(errors: T) {
  return (fieldName: keyof T) => handleErrorChange(errors, fieldName);
}

export function isValid<T>(errors: ErrorObject<T>): boolean {
  for (const v of Object.values(errors)) {
    if (v) {
      return false;
    }
  }
  return true;
}

export function hasErrors<T>(errors: ErrorObject<T>) {
  return !isValid(errors);
}

export function setupInputProps<T, U>(
  values: T,
  setValues: (o: T) => void,
  errors: ErrorObject<U>,
  setErrors: (o: ErrorObject<U>) => void
) {
  return {
    handleInputProps: (fieldName: keyof T & keyof U) => ({
      ...handleInputChange(values, setValues, fieldName),
      ...handleErrorChange(errors, fieldName),
    }),
    checkValidators: (errors: ErrorObject<U>) => {
      setErrors(errors);
      return isValid(errors);
    },
  };
}
Example use:
import { FormEvent, useState } from 'react';

import {
  Button,
  TextField,
} from '@mui/material';

import './App.css';

import { setupInputProps, type ErrorObject } from './utils/form';

class DonationCandidateDto {
  donationCandidateId = 0;
  fullname = '';
  mobile = '';
  email = '';
  age = 0;
  bloodGroup = '';
  address = '';
  active = false;
}

const initialFieldValues: DonationCandidateDto = {
  donationCandidateId: 0,
  fullname: '',
  mobile: '',
  email: '',
  age: 0,
  bloodGroup: '',
  address: '',
  active: false,
};

type FormType = typeof initialFieldValues;

// out-of-band validations
type OtherStates = {
  areZombiesInLab: boolean;
  day?: number;
};

function App() {
  const [values, setValues] = useState(structuredClone(initialFieldValues));
  const [errors, setErrors] = useState<ErrorObject<FormType & OtherStates>>({});

  const { handleInputProps, checkValidators } = setupInputProps(
    values,
    setValues,
    errors,
    setErrors
  );

  return (
    <div>
      <h1>Hello world</h1>
      <form autoComplete="off" noValidate onSubmit={handleSubmit}>
        <div>
          <TextField
            label="Full name"
            required={true}
            {...handleInputProps('fullname')}
          />
        </div>
        <br />
        <div>
          <TextField label="Age" {...handleInputProps('age')} />
        </div>

        <div>
          {errors.areZombiesInLab && (
            <div style={{ color: 'red' }}>{errors.areZombiesInLab}</div>
          )}
        </div>

        <br />
        <div>
          <Button color="primary" type="submit" variant="contained">
            Submit
          </Button>
          &nbsp;&nbsp;&nbsp;
          <Button type="submit" color="inherit" variant="contained">
            Reset
          </Button>
        </div>
      </form>
      <hr />
      {/* {JSON.stringify(values)} */}
    </div>
  );

  function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();

    const isValid = checkValidators({
      fullname: !values.fullname && 'Must have full name',
      age: !(values.age >= 18) && 'Must be an adult',
      // areZombiesInLab: true && 'Zombies in lab',
    });

    if (!isValid) {
      return;
    }

    // POST/PUT here
  }
}

export default App;
If the name is not existing from the DTO, it will not compile:













Sunday, June 15, 2025

TypeScript power ups autocomplete

DonationCandidateForm.tsx:
import {
    Button,
    FormControl,
    Grid,
    InputLabel,
    MenuItem,
    Select,
    TextField,
} from "@mui/material";
import { useState, type FormEvent } from "react";

import { setupInputProps, type ErrorObject } from "../utils";

const initialFieldValues = {
    fullname: "",
    mobile: "",
    email: "",
    age: 0,
    bloodGroup: "",
    address: "",
    favoriteColors: [],
};

type FormType = typeof initialFieldValues;

// out-of-band validations
type OtherStates = {
    areZombiesInLab: boolean;
    day?: number;
};

// const errors: Partial<Record<FormType, string>> = {};

// const initialErrors: ErrorObject<FormType> = {};

export default function DonationCandidateForm() {
    const [values, setValues] = useState(initialFieldValues);
    const [errors, setErrors] = useState<ErrorObject<FormType & OtherStates>>(
        {}
    );

    // const handleInputChange = setupInputChange(values, setValues);
    // const handleErrorChange = setupErrorChange(errors);

    const { handleInputProps, checkValidators } = setupInputProps(
        values,
        setValues,
        errors,
        setErrors
    );

    function handleSubmit(event: FormEvent<HTMLFormElement>): void {
        event.preventDefault();

        const otherStates: OtherStates = { areZombiesInLab: true };

        console.log(values);

        const isValid = checkValidators({
            fullname: !values.fullname && "Must have full name",
            age: !(values.age >= 18) && "Must be an adult",
            favoriteColors:
                !(values.favoriteColors.length > 0) &&
                "Must have favorite colors",
            areZombiesInLab:
                otherStates.areZombiesInLab &&
                "Zombies around, data won't be saved, you must escape now for your own safety",
        });

        if (!isValid) {
            return;
        }

        // if (hasErrors(errors)) {
        //     return;
        // }
    }

    return (
        <>
            {JSON.stringify(values)}
            <form autoComplete="off" noValidate onSubmit={handleSubmit}>
                <Grid container marginBottom={1}>
                    <Grid size={{ xs: 6 }}>
                        <TextField
                            label="Full name"
                            // error={true}
                            // helperText="Something"
                            required={true}
                            {...handleInputProps("fullname")}
                        />
                        <TextField
                            label="Mobile"
                            {...handleInputProps("mobile")}
                        />
                        <TextField
                            label="Email"
                            {...handleInputProps("email")}
                        />
                    </Grid>

                    <Grid size={{ xs: 6 }}>
                        <TextField label="Age" {...handleInputProps("age")} />
                        <FormControl>
                            <InputLabel>Blood Group</InputLabel>
                            <Select {...handleInputProps("bloodGroup")}>
                                <MenuItem value="">Select Blood Group</MenuItem>
                                <MenuItem value="A*">A *ve+</MenuItem>
                                <MenuItem value="A-">A -ve</MenuItem>
                                <MenuItem value="B+">B +ve</MenuItem>
                                <MenuItem value="B-">B -ve</MenuItem>
                                <MenuItem value="AB+">AB +ve</MenuItem>
                                <MenuItem value="AB-">AB -ve</MenuItem>
                                <MenuItem value="O+">O +ve</MenuItem>
                                <MenuItem value="O-">O -ve</MenuItem>
                            </Select>
                        </FormControl>
                        <TextField
                            label="address"
                            {...handleInputProps("address")}
                        />
                        <div>
                            <Button
                                color="primary"
                                type="submit"
                                variant="contained"
                            >
                                Submit
                            </Button>
                               
                            <Button
                                type="submit"
                                color="inherit"
                                variant="contained"
                            >
                                Reset
                            </Button>
                        </div>
                    </Grid>
                </Grid>
                {errors.favoriteColors && <div>{errors.favoriteColors}</div>}
                {errors.areZombiesInLab && <div>{errors.areZombiesInLab}</div>}
            </form>
        </>
    );
}
utils.ts:
type UnifiedChangeEvent =
    | React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
    | React.ChangeEvent<{ name?: string; value: unknown }>
    | (Event & { target: { name: string; value: unknown } });

export type ErrorObject<T> = Partial<Record<keyof T, string | boolean>>;

function handleInputChange<T>(
    values: T,
    setValues: (o: T) => void,
    fieldName: keyof T
) {
    return {
        onChange(event: UnifiedChangeEvent) {
            const { name, value } = event.target;
            setValues({ ...values, [name!]: value });
        },
        name: fieldName,
        value: values[fieldName],
    };
}

export function setupInputChange<T>(values: T, setValues: (o: T) => void) {
    return (fieldName: keyof T) =>
        handleInputChange(values, setValues, fieldName);
}

function handleErrorChange<T>(errors: T, fieldName: keyof T) {
    return errors[fieldName] && { error: true, helperText: errors[fieldName] };
}

export function setupErrorChange<T>(errors: T) {
    return (fieldName: keyof T) => handleErrorChange(errors, fieldName);
}

export function isValid<T>(errors: ErrorObject<T>): boolean {
    for (const v of Object.values(errors)) {
        if (v) {
            return false;
        }
    }
    return true;
}

export function hasErrors<T>(errors: ErrorObject<T>) {
    return !isValid(errors);
}

export function setupInputProps<T, U>(
    values: T,
    setValues: (o: T) => void,
    errors: ErrorObject<U>,
    setErrors: (o: ErrorObject<U>) => void
) {
    return {
        handleInputProps: (fieldName: keyof T & keyof U) => ({
            ...handleInputChange(values, setValues, fieldName),
            ...handleErrorChange(errors, fieldName),
        }),
        checkValidators: (errors: ErrorObject<U>) => {
            setErrors(errors);
            return isValid(errors);
        },
    };
}

Saturday, May 29, 2021

TypeScript Object is possibly undefined

   .
   .
   .
   
   newHzl[hanzi].pinyin = newHzl[hanzi].pinyin.filter(eachPinyin => pinyinEnglish[eachPinyin]);
} // for loop   
That yields this error:
error: TS2532 [ERROR]: Object is possibly 'undefined'.
        newHzl[hanzi].pinyin = newHzl[hanzi].pinyin.filter(
Got a similar error code on following code, and removed the error by checking if a variable has nothing, if nothing then skip(continue)
for (const [hanzi, { pinyinEnglish }] of Object.entries(hzl)) {
    if (!pinyinEnglish) {
        continue;
    }
    
    for (const pinyin of Object.keys(pinyinEnglish)) {
        if (pinyinEnglish[pinyin].length === 0) {
            delete pinyinEnglish[pinyin];
        }
    }
    
    .
    .
    .    
I tried to do the same solution on the post's first code, but it still yields a compile error of Object is possibly 'undefined'
	.
    .
    .
    
    if (!newHzl[hanzi].pinyin) {
        continue;
    }

    newHzl[hanzi].pinyin = newHzl[hanzi].pinyin.filter(eachPinyin => pinyinEnglish[eachPinyin]);
} // for loop    
The workaround is to introduce a variable so the compiler will not have a hard time inferring the code's control flow:
    .
    .
    .

    const hanziPinyin = newHzl[hanzi].pinyin;
    if (!hanziPinyin) {
        continue;
    }

    newHzl[hanzi].pinyin = hanziPinyin.filter(eachPinyin => pinyinEnglish[eachPinyin]);
} // for loop   
The problem went away

Saturday, April 17, 2021

TypeScript, array map can't be type-checked

Given this interface:
	interface IHskLevel {
       hanzi: string;
       hsk: number;
    }
This code does not produce compile error (it should):
function* generateHskLevel(): Iterable<IHskLevel> {
    yield* json.map(
        ({ hanzi, pinyin, HSK: hsk }) => ({ hanzi, pinyin, hsk })
    );
}
This produces compile error (it should):
function* generateHskLevel(): Iterable<IHskLevel> {
    for (const { hanzi, pinyin, HSK: hsk } of json) {
        yield { hanzi, pinyin, hsk };
    }
}
Error:
error: TS2322 [ERROR]: Type '{ hanzi: string; pinyin: string; hsk: number; }' is not assignable to type 'IHskLevel'.
  Object literal may only specify known properties, and 'pinyin' does not exist in type 'IHskLevel'.
            pinyin,

Friday, February 5, 2021

create react app + typescript script


% cat ~/.scripts/cra.sh 
#!/bin/zsh

if [ $# -eq 0 ] ; then
	echo "Pass the name of project"
	exit 1
fi

NAME="$1"

yarn dlx create-react-app $NAME --template typescript 

cd $NAME
yarn dlx @yarnpkg/pnpify --sdk vscode

yarn add react-refresh eslint-config-react-app

TO_DECLARE="declare module '@testing-library/react';"
printf $TO_DECLARE >> src/react-app-env.d.ts

code-insiders .

Sunday, August 16, 2020

Create React App with TypeScript and PnP (no npx)

#!/bin/zsh

if [ $# -eq 0 ] ; then
	echo "Pass the name of project"
	exit 1
fi

yarn set version berry

NAME="$1"

yarn dlx create-react-app $NAME --template typescript 

cd $NAME
yarn dlx @yarnpkg/pnpify --sdk vscode

TO_DECLARE="declare module '@testing-library/react';"
printf $TO_DECLARE >> src/react-app-env.d.ts

code .
The above is same as https://www.anicehumble.com/2020/08/create-react-app-with-typescript-and-pnp.html 

The previous one does not create hidden directory(.yarn) and file (.yarnrc.yml) on the parent directory of the project, while the script above do.

The advantage of the script above is almost everything works out of the box, but you just have to tolerate the creation of .yarn directory and .yarnrc.yml file on the directory where you run the create-react-app

Looks like it is safe to run "yarn set version berry" once on the home directory if you don't have any project that is dependent on yarn version 1. Setting yarn to berry on home folder would make yarn use the version 2 globally. With that said, the "yarn set version berry" above can be removed from the script.

If you set the yarn to version 2 globally and you need to go back to version 1, just delete the .yarn directory and .yarnrc file, yarn will run as version 1 again.

Saturday, August 15, 2020

Create React App with TypeScript and PnP

#!/bin/zsh

if [ $# -eq 0 ] ; then
	echo "Pass the name of project"
	exit 1
fi

NAME="$1"

npx create-react-app $NAME --template typescript
cd $NAME

yarn set version berry

yarn remove typescript
yarn add -D typescript
yarn dlx @yarnpkg/pnpify --sdk vscode

TO_DECLARE="declare module '@testing-library/react';"
printf $TO_DECLARE >> src/react-app-env.d.ts

TO_IGNORE="
.yarn/*
!.yarn/releases
!.yarn/plugins
!.yarn/sdks
!.yarn/versions
.pnp.*
"
printf $TO_IGNORE >> .gitignore

code .
https://reactjs.org/docs/create-a-new-react-app.html
https://next.yarnpkg.com/getting-started/install#updating-to-the-latest-versions
https://stackoverflow.com/questions/54954337/is-it-possible-to-use-yarn-pnp-with-typescript-vscode
https://create-react-app.dev/docs/adding-typescript/
https://yarnpkg.com/advanced/qa#which-files-should-be-gitignored


Another way: https://www.anicehumble.com/2020/08/create-react-app-with-typescript-and-pnp-no-npx.html

npm is installing...

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);
        }
    }
}

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)!;

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

Wednesday, March 27, 2019

For interface-free action props, use typeof

Instead of creating an interface for the actions that will be used by the component, just use typeof on actionCreators object. See line 54 and 63



A sample action creator (e.g., setColorTheme):

export const setColorTheme = (colorTheme: string): LoggedUserAction => ({
    type: LoggedUserActionType.LOGGED_USER__SET_COLOR_THEME,
    colorTheme
});

Wednesday, March 20, 2019

Flattening redux state while maintaining navigability

Let's say you have this interface for mapStateToProps:

// file 1

export interface ILoggedUserState
{
    username: string;
    email: string;
    lastLogged: Date;
}

// file 2

export interface IAllState
{
    loggedUser: ILoggedUserState;
    chosenTheme: IChosenTheme;
    menu: IMenu;
}

// file 3

interface IPropsToUse
{
    loggedUser: ILoggedUserState;
    chosenTheme: IChosenTheme;
}

export const mapStateToProps = ({loggedUser, chosenTheme}: IAllState): IPropsToUse => ({
    loggedUser,
    chosenTheme
});




And you want to flatten or use only a few fields from ILoggedUserState, say we only want to use the username only:

interface IPropsToUse
{
    username: string;
    chosenTheme: IChosenTheme;
}

export const mapStateToProps = ({loggedUser: {username}, chosenTheme}: IAllState): IPropsToUse => ({
    username,
    chosenTheme
});


The downside of that code is it cannot convey anymore where the IPropsToUse's username property come from.

Here's a better way to convey that IPropsToUse's username come frome ILoggedUserState's username field.

interface IPropsToUse
{
    username: ILoggedUserState['username'];
    chosenTheme: IChosenTheme;
}

export const mapStateToProps = ({loggedUser: {username}, chosenTheme}: IAllState): IPropsToUse => ({
    username,
    chosenTheme
});


The syntax looks odd, but it works. With the code above, we can convey that IPropsToUse's username come from ILoggedUserState's username, and IPropsToUse's username also receives the type of ILoggedUserState's username.

Saturday, June 2, 2018

Nameless interface with TypeScript

There are only two hard things in Computer Science: cache invalidation and naming things. -- PHil Karlton


Why give names to actions when your action and reducer can discriminate an action based on type (e.g., MyJobActionType) ?
export const enum MyJobActionType
{
    MY_JOB               = 'MY_JOB',

    MY_JOB_UI            = 'MY_JOB_UI',
    MY_JOB_DATA_FETCHING = 'MY_JOB_DATA_FETCHING',
    MY_JOB_DATA_FETCHED  = 'MY_JOB_DATA_FETCHED',

}

export type MyJobAction = IMyJobAction | IMyJobUIAction | IMyJobDataFetchingAction | IMyJobDataFetchedAction;

interface IMyJobAction extends Action
{
    type: MyJobActionType.MY_JOB;
    payload?: {
        jobId: 'new' | number;
    };
}

interface IMyJobUIAction extends Action
{
    type: MyJobActionType.MY_JOB_UI;
    ui: React.ComponentClass;
}

interface IMyJobDataFetchingAction extends Action
{
    type: MyJobActionType.MY_JOB_DATA_FETCHING;
}

interface IMyJobDataFetchedAction extends Action
{
    type: MyJobActionType.MY_JOB_DATA_FETCHED;
    data: IPagedDto<IPagedMyJobPostDto>;
}

Reducer:
export const myJobViewModelReducer = produce((
    draft: IMyJobViewModel = {
        view : null,
        model: {
            gridData: no.data
        }
    },
    action: MyJobAction
) =>


Action:
async function loadUI(dispatch: Dispatch<MyJobAction>)
{
    const component = (await import(/* webpackChunkName: 'myJob' */ './')).default;

    const uiAction: MyJobAction = {
        type: MyJobActionType.MY_JOB_UI,
        ui  : component
    };

    await dispatch(uiAction);
}


So don't name things then:
export type MyJobAction =
    {
        type: MyJobActionType.MY_JOB;
        payload?: {
            jobId: 'new' | number;
        };
    }
    |
    {
        type: MyJobActionType.MY_JOB_UI;
        ui: React.ComponentClass;
    }
    |
    {
        type: MyJobActionType.MY_JOB_DATA_FETCHING;
    }
    |
    {
        type: MyJobActionType.MY_JOB_DATA_FETCHED;
        data: IPagedDto<IPagedMyJobPostDto>;
    }
    ;

Are you worried someone might use the wrong payload (e.g., payload, ui, data) when dispatching an action? Don't worry, TypeScript is a smart language.




And just like that, TypeScript can infer that only the ui property is if a good discriminator is used, e.g., type: MyJobActionType.MY_JOB_UI




By the way, don't use the Dispatch type definition from redux, use the one from react-redux, it has more comprehensive type-checking. Upgraded to Redux 4, no more type definition problem.


So if you want TypeScript to type-check your direct object parameter to dispatch function, use react-redux's Dispatch type definition instead: Use Redux 4.0 instead, it comes with the correct type definition.



Finally, correct the properties of the object structure that matches type: MyJobActionType.MY_JOB_UI




Less interfaces, less names need to come up with.

Monday, May 28, 2018

TypeScript is the best for Redux


With the following payload signature..

import { Action } from 'redux';

import { IProfileData } from './model';

export const enum ProfileActionType
{
    PROFILE           = 'PROFILE',
    PROFILE_UI        = 'PROFILE_UI',
    PROFILE_DATA      = 'PROFILE_DATA',
    PROFILE_POST_DATA = 'PROFILE_POST_DATA'
}

export interface IProfileAction extends Action
{
    type: ProfileActionType;

    payload: Partial<{
        [ProfileActionType.PROFILE_UI]: React.ComponentClass;

        [ProfileActionType.PROFILE_DATA]: IProfileData;
    }>;
}


..it's still possible to wrongly choose the correct payload:






And you cannot even pass the correct property as-is:



You must use the null-assertion operator, exclamation mark:




A better way would be is to separate the action data structures for UI and Data, and lump them in an action selector (ProfileAction):

export type ProfileAction = IProfileUIAction | IProfileDataAction;

interface IProfileUIAction extends Action
{
    type: ProfileActionType.PROFILE_UI;
    component: React.ComponentClass;
}

interface IProfileDataAction extends Action
{
    type: ProfileActionType.PROFILE_DATA;
    data: IProfileData;
}

With that, only the component property of ProfileAction will be available for ProfileActionType.PROFILE_UI:



Likewise with ProfileActionType.PROFILE_DATA, only the data property of ProfileAction will be available from ProfileActionType's intellisense.



And that is not just an intellisense feature, it's a language feature. So if you try to get the data property from ProfileAction when the condition (switch case ProfileActionType.PROFILE_UI) is in ProfileActionType.PROFILE_UI, it will not be possible. So awesome!




Likewise when the condition is in ProfileActionType.PROFILE_DATA, you cannot selection action's component property:




You can only choose data property from the action when the condition is in ProfileActionType.PROFILE_DATA:




TypeScript truly feels magical that it can infer the right data structure based on the condition. If you try to select the properties from action outside of switch condition, you can't select the component property neither the data property.




It's also a compiler error:




It's also a compiler error to create an object that does not match from the type's selector:




You can only create an object that matches the type: ProfileActionType.PROFILE_UI

Saturday, May 26, 2018

Typescript properties concatenation

TypeScript has a unique functionality, it can concatenate properties inline.

Given these following classes from C#
public class PagedDto<T>
{
    public IList<T> Data { get; set; }
    public int Pages { get; set; }
}

public class PagedCityDto
{
    public int CityId { get; set; }
    public string CityName { get; set; }        

    public int StateId { get; set; }
    public string StateName { get; set; }
}   


The above are translated to these:
export interface IPagedDto<T>
{
    data: T[];
    pages: number;
}

export interface IPagedCityDto
{
    cityId: number;
    cityName: string;

    stateId: number;
    stateName: string;
}


Then the following properties concatenation..
interface IComponentState
{
    grid: IPagedDto<IPagedCityDto> & {
        loading: boolean;
    };
}

..is expanded to:
interface IComponentState
{
    grid: {
        data: IPagedCityDto[];
        pages: number;
        loading: boolean;
    };
}


Why would we do concatenation? Why not just put the loading property out-of-band from grid property like this?
interface IComponentState
{
    grid: IPagedDto<IPagedCityDto>;
    isLoading: boolean;   
}

Well we need those three properties to be grouped in one property (i.e., grid) as they are related to each other. Besides, putting the isLoading outside of grid object is a cognitive load for other devs reading your code, the code would give them impression that isLoading property is related to the whole component and not specific to grid. One might say, that it can be renamed to isGridLoading to signify it pertains to the grid only.

interface IComponentState
{
    grid: IPagedDto<IPagedCityDto>;
    isGridLoading: boolean;   
}

Still the best way to write readable code is to group related things together, not just by naming convention, but through whatever the best mechanism the programming language can provide to the developer. The following is better than the code above.

interface IComponentState
{
    grid: {
        data: IPagedCityDto[];
        pages: number;
        loading: boolean;
    };
}

Better yet, use TypeScript's built-in concatenation mechanism:
interface IComponentState
{
    grid: IPagedDto<IPagedCityDto> & {
        loading: boolean;
    };
}

There's also a symmetry when grouped properties are used:

<ReactTable
    manual={true}
    columns={[
        {
            Header  : <Typography>City</Typography>,
            accessor: 'cityName',
            id      : 'City',
            Cell    : row => (
                <Typography>
                    <a href={'/city/' + row.original.cityId}>{row.value}</a>
                </Typography>
            )
        },
        {
            Header  : <Typography>State</Typography>,
            accessor: 'stateName',
            id      : 'State'
        }
    ]}
    onFetchData={this.fetchData}
    data={this.state.grid.data}
    pages={this.state.grid.pages}
    loading={this.state.grid.loading}
    defaultPageSize={10}
    className='-striped -highlight'
/>


Another nice thing when related properties are grouped together, it makes the code shorter and idiomatic when used with spread operator:

<ReactTable
    manual={true}
    columns={[
        {
            Header  : <Typography>City</Typography>,
            accessor: 'cityName',
            id      : 'City',
            Cell    : row => (
                <Typography>
                    <a href={'/city/' + row.original.cityId}>{row.value}</a>
                </Typography>
            )
        },
        {
            Header  : <Typography>State</Typography>,
            accessor: 'stateName',
            id      : 'State'
        }
    ]}
    onFetchData={this.fetchData}
    {...this.state.grid}
    defaultPageSize={10}
    className='-striped -highlight'
/>


Happy Coding!

React does not recognize the `inputRef` prop on a DOM element

With this code:
<Field
    name='locationTypeFk'
    id='locationTypeFk'
    label='Location Type'
    component={RadioGroupField}
    validate={required}
    margin='normal'
    fullWidth={true}
    required={true}
>
    {this.state.locationTypes.map(l =>
        <label key={l.id}>
            <Field
                name='locationTypeFk'
                component={RadioField}
                type='radio'
                color='primary'
                value={l.id}
            />
            <strong>{l.name}</strong>
        </label>
    )}
</Field>

I got this warning:
warning.js:33 Warning: React does not recognize the `inputRef` prop on a DOM element. If you intentionally want it to appear in the DOM as a custom attribute, spell it as lowercase `inputref` instead. If you accidentally passed it from a parent component, remove it from the DOM element.


To solve that, use <React.Fragment>, you can also use React.Fragment's shorthand, i.e., <>

<Field
    name='locationTypeFk'
    id='locationTypeFk'
    label='Location Type'
    component={RadioGroupField}
    validate={required}
    margin='normal'
    fullWidth={true}
    required={true}
>
    <>
        {this.state.locationTypes.map(l =>
            <label key={l.id}>
                <Field
                    name='locationTypeFk'
                    component={RadioField}
                    type='radio'
                    color='primary'
                    value={l.id}
                />
                <strong>{l.name}</strong>
            </label>
        )}
    </>
</Field>

React.Fragment will capture the inputRef prop instead of it being passed to label. At least that's how I understand it.


Happy Coding!

Monday, May 21, 2018

Typescript definition for process.env

// autocomplete for process.env.
declare global
{
    namespace NodeJS
    {
        // tslint:disable interface-name
        interface ProcessEnv
        {
            NODE_ENV?: 'production' | 'development';
            FACEBOOK_CLIENT_ID: string;
            FACEBOOK_CLIENT_SECRET: string;
            GOOGLE_CLIENT_ID: string;
            GOOGLE_CLIENT_SECRET: string;
            JWT_SIGNING_KEY: string;
        }
    }
}

// https://www.typescriptlang.org/docs/handbook/declaration-files/templates/global-modifying-module-d-ts.html
export {};


Happy coding!

Friday, May 18, 2018

Mutating React state

This won't work. React can't detect mutation when done this way:
interface IComponentState
{
    numberList: number[];
}

export class HomeComponent extends React.Component<ComponentProps, IComponentState>
{
    private addNumber(): void
    {
        this.state.numberList.push(Math.random());
    }
}

This works, but would have to produce immutable explicitly:
private addNumber(): void
{
    const newArray = [...this.state.numberList, Math.random()];
    this.setState({numberList: newArray});    
}


Producing immutable the convenient way, use immer:
private addNumber(): void
{
    // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/18365#issuecomment-390110697

    this.setState(produce<IComponentState>(draft => {
        draft.numberList.push(Math.random());
    }));
}


Deleting an element from an array:
{this.state.numberList.map((n, i) =>
    <div key={i}>
        {n}
        <button
            // tslint:disable jsx-no-lambda
            onClick={() => {
                this.state.numberList.splice(i, 1);
            }}
        >
            Non-working delete
        </button>

        <button
            // tslint:disable jsx-no-lambda
            onClick={() => {
                this.setState({
                    numberList: [
                        ...this.state.numberList.slice(0, i),
                        ...this.state.numberList.slice(i + 1)
                    ]
                });
            }}
        >
            Delete using explicit immutable
        </button>

        <button
            // tslint:disable jsx-no-lambda
            onClick={() => {
                this.setState(produce<IComponentState>(draft => {
                    draft.numberList.splice(i, 1);
                }));
            }}
        >
            Delete the easier way
        </button>
    </div>
)}



Happy Coding!

Monday, May 14, 2018

Mutating Redux state

Mutating Redux state directly does not work.

export interface ICounterMore
{
    count: number;
    message: string;
}

export function counterReducer(
    state: ICounterMore = {count: 0, message: 'hello'},
    action: ICounterAction
): ICounterMore
{
    switch (action.type) {
        case CounterKind.INCREMENT:
            state.count = state.count + 1;
            return state;
        case CounterKind.DECREMENT:
            state.count = state.count - 1;
            state.message = 'hey';
            return state;
        default:
            return state;
    }
}


Must return new state that does not mutate the existing state.

export function counterReducer(
    state: ICounterMore = {count: 0, message: 'hello'},
    action: ICounterAction
): ICounterMore
{
    switch (action.type) {
        case CounterKind.INCREMENT:
            return {
                ...state,
                count: state.count + 1
            };
        case CounterKind.DECREMENT:
            return {
                ...state,
                count: state.count - 1,
                message: 'hey'
            };
        default:
            return state;
    }
}


Mutating Redux state "directly" works, using immer.

immer creates immutable state from the mutated state from draft, and then it return the immutable state to Redux.
Proof that immutable state is the state that is still being passed around, Redux's time travel still work.


export function counterReducer(
    state: ICounterMore = {count: 0, message: 'hello'},
    action: ICounterAction
): ICounterMore
{
    return produce(state, draft => {
        switch (action.type) {
            case CounterKind.INCREMENT:
                ++draft.count;
                break;
            case CounterKind.DECREMENT:
                --draft.count;
                draft.message = 'hey';
                break;
        }
    });
}

Happy Coding!