Coder Social home page Coder Social logo

blog's People

Contributors

rareyesdev avatar

Watchers

 avatar

blog's Issues

Standard React Component

When working in FlowJS codebases I've found that there is no standard way of typing the component. These are some of the patterns I've encountered:

function MyComponent(props: Props) {
    return <>JSX here</>
}
const MyComponent = (props: Props) => {
    return <>JSX here</>
}

In both cases, only the input is being typed. Nothing prevents us from making mistakes and returning something that is not a ReactElement.

Can we do better?

We can consider enforcing a consistent way of defining a React Component in TypeScript like this:

import { FC } from 'react';

interface Props {
    name: string;
}

const MyComponent: FC<Props> = ({ name }) => {
    return <div>Hello {name}</div>
};

This allows for type-safe access to the props and ensures that we always return valid React Elements.

As a bonus, I recommend you configure @typescript-eslint/ban-types to ensure a single way to import the component type definition (example ESLint Config).

"@typescript-eslint/ban-types": [
    "error",
    {
        types: {
            "React.FC": {
                message: "Use FC instead",
                fixWith: "FC",
            },
            "React.FunctionComponent": {
                message: "Use FC instead",
                fixWith: "FC",
            },
            FunctionComponent: {
                message: "Use FC instead",
                fixWith: "FC",
            },
        },
        extendDefaults: true,
    },
]

Migrating to React 18

The type definitions for react@17 implicitly add an optional children prop to each component. This means that you can destructure children without having to define it in the component props. the team behind @types/react decided to finally remove this implicit prop after years of consideration (this has nothing to do with react@18 features, Semantic Versioning works different for type packages, you can read more here)

The important thing to note is that after upgrading to react@18 you might encounter errors when a component uses its children prop without explicitly declaring it.

Declaring a children prop is often difficult for developers who sometimes confuse ReactNode with ReactElement (not mentioning the fortunately deprecated types ReactText and ReactChild). For this reason, it's encouraged that children props be defined like this:

import { FC, PropsWithChildren } from 'react';

const MyComponent: FC<PropsWithChildren<Props>> = ({ children, name }) => {
    return (
        <div>
            <div>Hello {name}</div>
            <div>{children}</div>
        </div>
    );
};

This will add the children prop back with the correct type.

Exhaustive conditions

In the scenario in which we need to do some conditional logic based on a discriminant value we see code like this:

calculateRideFare, calculateDeliveryFare, and calculateDeliveryFare2 are just fake methods that simulate that we are doing different things on each branch.

Option 1: Uses else clause

const calculateFare1 = (product: Product) => {
    if (product.type === 'RIDE') {
        const rideFare = 0; // API call to get the fare.
        return calculateRideFare(product, rideFare);
    } else if (product.type === 'FOOD_DELIVERY') {
        const deliveryFare = 0; // API call to get the fare.
        return calculateDeliveryFare(product, deliveryFare);
    } else {
        const deliveryFare = 0; // API call to get the fare.
        return calculateDeliveryFare2(product, deliveryFare);
    }
};

Option 2: Uses only IF statements and throws.

const calculateFare2 = (product: Product) => {
    if (product.type === 'RIDE') {
        const rideFare = 0; // API call to get the fare.
        return calculateRideFare(product, rideFare);
    } else if (product.type === 'FOOD_DELIVERY') {
        const deliveryFare = 0; // API call to get the fare.
        return calculateDeliveryFare(product, deliveryFare);
    } else if (product.type === 'ITEM_DELIVERY') {
        const deliveryFare = 0; // API call to get the fare.
        return calculateDeliveryFare2(product, deliveryFare);
    }

    throw new Error(`Unhandled product type ${product.type}`);
};

Option 3: Uses a switch and improves readability.

const calculateFare3 = (product: Product) => {
    switch (product.type) {
        case 'RIDE': {
            const rideFare = 0; // API call to get the fare.
            return calculateRideFare(product, rideFare);
        }
        case 'FOOD_DELIVERY': {
            const deliveryFare = 0; // API call to get the fare.
            return calculateDeliveryFare(product, deliveryFare);    
        }
        case 'ITEM_DELIVERY': {
            const deliveryFare = 0; // API call to get the fare.
            return calculateDeliveryFare2(product, deliveryFare);            
        }
        default:
            throw new Error(`Unhandled product type ${product.type}`);
    }
};

What happens if later on, we introduce a new product type?

Option 1 is pretty bad, there is nothing set in place to alert us that some code needs to be updated. In cases like this we have to start hunting product.type all around. Because of the hight frequency of this kind of conditional logic this is the root of many bugs.

Option 2 and 3 make it a bit easier by throwing an error that will (hopefully) be caught during development as we implement the happy path. There is still a chance that a non happy path stays outdated (even more if the type is used for more than one application).

Can we do better?

Ideally, we want to set some restriction around each use of this discriminant logic so that a new possible value will trigger a type error anywhere the value is being conditionally tested.

We can archive this in TypeScript using an exhaustive switch like this:

Option 4: Uses a an exhaustive switch.

const assertUnreachable = (value: never): never => {
    throw new Error(`No such case in exhaustive switch: ${value}`);
}

const calculateFare4 = (product: Product) => {
    switch (product.type) {
        case 'RIDE': {
            const rideFare = 0; // API call to get the fare.
            return calculateRideFare(product, rideFare);
        }
        case 'FOOD_DELIVERY': {
            const deliveryFare = 0; // API call to get the fare.
            return calculateDeliveryFare(product, deliveryFare);    
        }
        case 'ITEM_DELIVERY': {
            const deliveryFare = 0; // API call to get the fare.
            return calculateDeliveryFare2(product, deliveryFare);            
        }
        default:
            return assertUnreachable(product.type);
    }
};

When a new type is added TypeScript will complain everywhere and we can easily add new logic to handle the new product type. I was not able to do something similar when using FlowJS.

Another nice benefit of consistently using switch for these cases is that we can easily group logic that requires the same handling:

const calculateFare4 = (product: Product) => {
    switch (product.type) {
        case 'RIDE': {
            const rideFare = 0; // API call to get the fare.
            return calculateRideFare(product, rideFare);
        }
        case 'FOOD_DELIVERY':
        case 'ITEM_DELIVERY': {
            const deliveryFare = 0; // API call to get the fare.
            return calculateDeliveryFare(product, deliveryFare);    
        }
        default:
            return assertUnreachable(product.type);
    }
};

You can read more about the never type here

TypeScript Playground

Other solutions

These solutions are less flexible and may not be available depending on the project configuration. For this reason, Option 4 is a very good pattern to standardize in the codebase.

You don't need enums

Enums are available in both Flow and TypeScript. Codebases may also use complimentary techniques like keymirror.

Issues with keymirror:

  • More verbose than using string literals.
  • No type definitions (by default).

In both type systems, using enums has several drawbacks.

In many cases, String Literals are a better tool for the usual Enum use case. You can read more about this here.

One additional way to use String Literals not described in the article above is how to use String Literals when the list of values is also needed.

Consider an enum for a log level:

enum LogLevel {
  INFO = "INFO",
  WARNING = "WARNING",
  ERROR = "ERROR",
}

Maybe some code needs to go through all the keys and so something with it (for example show a dropdown with all possible values):

const dropdownValues = Object.values(LogLevel).map((value) => ({ label: translate(value), value: key }));

This is often repeated many times in the code with potential issues. A common mistake is to confuse keys and values when iterating over the object. Trying to build the dropdown options using Object.values(...) will implicitly assume keys and values will always be the same. This bug could be caught by the type system only if the usage is also typed properly (translate function in this case).

As an alternative, we can define a String Literal like this:

const LogLevels = [
    "INFO",
    "WARNING",
    "ERROR",
] as const;
type LogLevel = typeof LogLevels[number];

This way you define the keys only once and keep the codebase free of duplicated code (example in TypeScript Playground).

Useful readings

Optional values in Flow and TypeScript

Intro

In Flow there are multiple ways to express that a value is not required:

  1. Question mark close to the property/argument.

This accepts either no value to be passed or a value whose type that extends void.

type Rider = {|
  phoneNumber?: PhoneNumber;
|};

const getValue = (x?: string) => {...}
  1. Question mark close to the type.

This requires a value to be passed but the value's type can extend void | null.

type Rider = {|
  phoneNumber: ?PhoneNumber;
|};

const getValue = (x: ?string) => {...}

In TypeScript case (1) uses the same syntax while case (2) needs a longer more explicit syntax:

type Rider = {|
  phoneNumber: PhoneNumber | null | undefined;
|};

const getValue = (x: string | null | undefined) => {...}

Action

Consistent optional value definitions

When migrating from Flow to TypeScript, consider using (1) and avoiding (2) entirely. This recommendation is based on You don't need null. Use undefined instead. Please refer to the linked article to understand the rationale behind this recommendation.

Improve types when mapping backend values.

Many backend services generate weak IDL types in which most properties are "double optional". As a result, the generated Flow and TypeScript definitions are bad.
Don't leak those weak types into your code. Create an adaptor layer in your RPC handlers or GraphQL resolvers to map to better types. You can read how the Guest Rides team does this in the project documentation: How to map from backend to frontend types?.

Handling of optional values

Optional will exist in your code but you should try to minimize them. You should also minimize the places in which a value must be checked for presence.

For those few places in which you need to check, use an assertion function.

You don't need null. Use undefined instead

TLDR;

You don't need null. Use undefined instead.

  • Having two non-values in JavaScript is now considered a design mistake (even by JavaScript’s creator, Brendan Eich and Douglas Crockford).
  • The creator of null pointers Tony Hoare is known for calling his own creation a "billion-dollar mistake". One nullish value is bad enough.
  • You get smaller API payloads and less code overall.
  • It will dramatically reduce the amount and complexity of presence checks in the code.

Counter arguments

What if I want to define a nullish value intentionally?

In that case, just assign undefined to it:

const anObject = {
    ...otherObject,
    propertyToNullify: undefined,
};

But what about the semantic differences?

Folks that use it a lot (generally coming from other languages that have null as the only nullish value) get pretty mad about such claims. The most common response I get is:

null is for intentional missing values, and undefined should be used when the values were never set in the first place.

The first thing I think with responses like that is: Why would you ever need to make that distinction? Both are "nullish", and you don't need to differentiate between "intentionally missing" and "unintentionally missing".

const people = [
    {
        firstName: "Luke",
        middleName: null,
        lastName: "Shiru",
    },
    {
        firstName: "Barack",
        middleName: "Hussein",
        lastName: "Obama",
    },
];

But you can just omit middleName when the user doesn't have one:

const people = [
    {
        firstName: "Luke",
        lastName: "Shiru",
    },
    // ...
];

And you can set middleName to an empty string if the user intentionally left that blank, if you really need to know that for some reason:

const people = [
    {
        firstName: "Luke",
        middleName: "",
        lastName: "Shiru",
    },
    // ...
];

And the TypeScript representation would be something like this:

type Person = {
    firstName: string;
    middleName?: string;
    lastName: string;
};

But document.querySelector('wrong-selector') returns null

Yes, it does, and it's a pain, but you can easily type that like this instead:

const element = document.querySelector('wrong-selector') ?? undefined;

But the API is returning null

  • Use an API wrapper. Instead of "spreading" null all over your codebase, update your surface of contact with the API so nulls are removed
  • If you have any contact with the folks making the API, voice your concern of making API responses smaller by getting rid of null values. You should try to avoid ending up over-engineering/over-complicating your app just to deal with null when you can just avoid it altogether.

The nullish coalescing operator ?? can be used to convert from null to undefined anywhere a null value is received from an external API.

But in JSON, at least, I can use null?

In JSON structures undefined cannot be used as that type does not exist. You can hover assign null to a property {"someProperty": null}, but why would you do that? If the property someProperty has no value, it's better to just exclude that property completely. Keeping such properties just increases the payload size that needs to be sent over the network or stored in a file without actually giving you any extra benefits.

Action items

Don’t compare to null

If your code base interacts with other APIs that might give you a null the use an == check instead of ===. value == undefined will be true for null and undefined but not for other falsy values ('', false, 0).

/// Imaging you are doing `value == undefined` where value can be one of:
console.log(undefined == undefined); // true
console.log(null == undefined); // true
console.log(0 == undefined); // false
console.log('' == undefined); // false
console.log(false == undefined); // false

Don’t initialize optional sub-properties

Just don’t set it. Your type annotation should have it as optional anyways. Don’t set it to null or undefined. Just have it as absent:

interface Foo {
 a: number;
 b?: number;
}

// Don't set `b` to anything.
const foo: Foo = {
  a: 123
};

Arrays (type and value) should never be left uninitialized

Setting any array to undefined will trigger presence checks down the line. Initialize all arrays to empty instead.

Okay okay, this might make sense, how can I force all my colleagues into submission?

That is not a nice thought, anyhow, ask them nicely and then add the eslint-plugin-no-null plugin to your .eslintrc:

{
  "plugins": [
    "no-null"
  ],
  "rules": {
    "no-null/no-null": 2
  }
}

Exceptions

Never use null, unless the ecosystem dictates it.
e.g. node style callbacks expect the error argument to be set to null in case of no error. Even then I (and pretty much everyone else) do a truthy check on null and not an explicit one.

fs.readFile('someFile', 'utf8', (err,data) => {
  if (err) {
    // do something
  }
  // no error
});

References

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.