Skip to content

Latest commit

 

History

History
746 lines (554 loc) · 27 KB

TS_STYLE.md

File metadata and controls

746 lines (554 loc) · 27 KB

Expensify TypeScript Style Guide

Table of Contents

Other Expensify Resources on TypeScript

General Rules

Strive to type as strictly as possible.

type Foo = {
  fetchingStatus: "loading" | "success" | "error"; // vs. fetchingStatus: string;
  person: { name: string; age: number }; // vs. person: Record<string, unknown>;
};

Guidelines

  • 1.1 Naming Conventions: Follow naming conventions specified below

    • Use PascalCase for type names. eslint: @typescript-eslint/naming-convention

      // BAD
      type foo = ...;
      type BAR = ...;
      
      // GOOD
      type Foo = ...;
      type Bar = ...;
    • Do not postfix type aliases with Type.

      // BAD
      type PersonType = ...;
      
      // GOOD
      type Person = ...;
    • Use singular name for union types.

      // BAD
      type Colors = "red" | "blue" | "green";
      
      // GOOD
      type Color = "red" | "blue" | "green";
    • Use {ComponentName}Props pattern for prop types.

      // BAD
      type Props = {
        // component's props
      };
      
      function MyComponent({}: Props) {
        // component's code
      }
      
      // GOOD
      type MyComponentProps = {
        // component's props
      };
      
      function MyComponent({}: MyComponentProps) {
        // component's code
      }
    • For generic type parameters, use T if you have only one type parameter. Don't use the T, U, V... sequence. Make type parameter names descriptive, each prefixed with T.

      Prefix each type parameter name to distinguish them from other types.

      // BAD
      type KeyValuePair<T, U> = { key: K; value: U };
      
      type Keys<Key> = Array<Key>;
      
      // GOOD
      type KeyValuePair<TKey, TValue> = { key: TKey; value: TValue };
      
      type Keys<T> = Array<T>;
      type Keys<TKey> = Array<TKey>;

  • 1.2 d.ts Extension: Do not use d.ts file extension even when a file contains only type declarations. Only exceptions are src/types/global.d.ts and src/types/modules/*.d.ts files in which third party packages and JavaScript's built-in modules (e.g. window object) can be modified using module augmentation. Refer to the Communication Items section to learn more about module augmentation.

    Why? Type errors in d.ts files are not checked by TypeScript 1.

  • 1.3 Type Alias vs. Interface: Do not use interface. Use type. eslint: @typescript-eslint/consistent-type-definitions

    Why? In TypeScript, type and interface can be used interchangeably to declare types. Use type for consistency.

    // BAD
    interface Person {
      name: string;
    }
    
    // GOOD
    type Person = {
      name: string;
    };

  • 1.4 Enum vs. Union Type: Do not use enum. Use union types. eslint: no-restricted-syntax

    Why? Enums come with several pitfalls. Most enum use cases can be replaced with union types.

    // Most simple form of union type.
    type Color = "red" | "green" | "blue";
    function printColors(color: Color) {
      console.log(color);
    }
    
    // When the values need to be iterated upon.
    import { TupleToUnion } from "type-fest";
    
    const COLORS = ["red", "green", "blue"] as const;
    type Color = TupleToUnion<typeof COLORS>; // type: 'red' | 'green' | 'blue'
    
    for (const color of COLORS) {
      printColor(color);
    }
    
    // When the values should be accessed through object keys. (i.e. `COLORS.Red` vs. `"red"`)
    import { ValueOf } from "type-fest";
    
    const COLORS = {
      Red: "red",
      Green: "green",
      Blue: "blue",
    } as const;
    type Color = ValueOf<typeof COLORS>; // type: 'red' | 'green' | 'blue'
    
    printColor(COLORS.Red);

  • 1.5 unknown vs. any: Don't use any. Use unknown if type is not known beforehand. eslint: @typescript-eslint/no-explicit-any

    Why? any type bypasses type checking. unknown is type safe as unknown type needs to be type narrowed before being used.

    const value: unknown = JSON.parse(someJson);
    if (typeof value === 'string') {...}
    else if (isPerson(value)) {...}
    ...

  • 1.6 T[] vs. Array<T>: Use T[] or readonly T[] for simple types (i.e. types which are just primitive names or type references). Use Array<T> or ReadonlyArray<T> for all other types (union types, intersection types, object types, function types, etc). eslint: @typescript-eslint/array-type

    // Array<T>
    const a: Array<string | number> = ["a", "b"];
    const b: Array<{ prop: string }> = [{ prop: "a" }];
    const c: Array<() => void> = [() => {}];
    
    // T[]
    const d: MyType[] = ["a", "b"];
    const e: string[] = ["a", "b"];
    const f: readonly string[] = ["a", "b"];

  • 1.7 @ts-ignore: Do not use @ts-ignore or its variant @ts-nocheck to suppress warnings and errors.

    Use @ts-expect-error during the migration for type errors that should be handled later. Refer to the Migration Guidelines for specific instructions on how to deal with type errors during the migration. eslint: @typescript-eslint/ban-ts-comment

  • 1.8 Optional chaining and nullish coalescing: Use optional chaining and nullish coalescing instead of the get lodash function. eslint: no-restricted-imports

    // BAD
    import lodashGet from "lodash/get";
    const name = lodashGet(user, "name", "default name");
    
    // GOOD
    const name = user?.name ?? "default name";

  • 1.9 Type Inference: When possible, allow the compiler to infer type of variables.

    // BAD
    const foo: string = "foo";
    const [counter, setCounter] = useState<number>(0);
    
    // GOOD
    const foo = "foo";
    const [counter, setCounter] = useState(0);
    const [username, setUsername] = useState<string | undefined>(undefined); // Username is a union type of string and undefined, and its type cannot be inferred from the default value of undefined

    For function return types, default to always typing them unless a function is simple enough to reason about its return type.

    Why? Explicit return type helps catch errors when implementation of the function changes. It also makes it easy to read code even when TypeScript intellisense is not provided.

    function simpleFunction(name: string) {
      return `hello, ${name}`;
    }
    
    function complicatedFunction(name: string): boolean {
      // ... some complex logic here ...
      return foo;
    }

  • 1.10 JSDoc: Omit comments that are redundant with TypeScript. Do not declare types in @param or @return blocks. Do not write @implements, @enum, @private, @override. eslint: jsdoc/no-types

    Not all parameters or return values need to be listed in the JSDoc comment. If there is no comment accompanying the parameter or return value, omit it.

    // BAD
    /**
     * @param {number} age
     * @returns {boolean} Whether the person is a legal drinking age or nots
     */
    function canDrink(age: number): boolean {
      return age >= 21;
    }
    
    // GOOD
    /**
     * @returns Whether the person is a legal drinking age or nots
     */
    function canDrink(age: number): boolean {
      return age >= 21;
    }

    In the above example, because the parameter age doesn't have any accompanying comment, it is completely omitted from the JSDoc.

  • 1.11 propTypes and defaultProps: Do not use them. Use object destructing to assign default values if necessary.

    Refer to the propTypes Migration Table on how to type props based on existing propTypes.

    Assign a default value to each optional prop unless the default values is undefined.

    type MyComponentProps = {
      requiredProp: string;
      optionalPropWithDefaultValue?: number;
      optionalProp?: boolean;
    };
    
    function MyComponent({
      requiredProp,
      optionalPropWithDefaultValue = 42,
      optionalProp,
    }: MyComponentProps) {
      // component's code
    }

  • 1.12 Utility Types: Use types from TypeScript utility types and type-fest when possible.

    type Foo = {
      bar: string;
    };
    
    // BAD
    type ReadOnlyFoo = {
      readonly [Property in keyof Foo]: Foo[Property];
    };
    
    // GOOD
    type ReadOnlyFoo = Readonly<Foo>;

  • 1.13 object: Don't use object type. eslint: @typescript-eslint/ban-types

    Why? object refers to "any non-primitive type," not "any object". Typing "any non-primitive value" is not commonly needed.

    // BAD
    const foo: object = [1, 2, 3]; // TypeScript does not error

    If you know that the type of data is an object but don't know what properties or values it has beforehand, use Record<string, unknown>.

    Even though string is specified as a key, Record<string, unknown> type can still accepts objects whose keys are numbers. This is because numbers are converted to strings when used as an object index. Note that you cannot use symbols for Record<string, unknown>.

    function logObject(object: Record<string, unknown>) {
      for (const [key, value] of Object.entries(object)) {
        console.log(`${key}: ${value}`);
      }
    }

  • 1.14 Prop Types: Don't use ComponentProps to grab a component's prop types. Go to the source file for the component and export prop types from there. Import and use the exported prop types.

    Don't export prop types from component files by default. Only export it when there is a code that needs to access the prop type directly.

    // MyComponent.tsx
    export type MyComponentProps = {
      foo: string;
    };
    
    export default function MyComponent({ foo }: MyComponentProps) {
      return <Text>{foo}</Text>;
    }
    
    // BAD
    import { ComponentProps } from "React";
    import MyComponent from "./MyComponent";
    type MyComponentProps = ComponentProps<typeof MyComponent>;
    
    // GOOD
    import MyComponent, { MyComponentProps } from "./MyComponent";

  • 1.15 File organization: In modules with platform-specific implementations, create types.ts to define shared types. Import and use shared types in each platform specific files. Do not use satisfies operator for platform-specific implementations, always define shared types that complies with all variants.

    Why? To encourage consistent API across platform-specific implementations. If you're migrating module that doesn't have a default implement (i.e. index.ts, e.g. getPlatform), refer to Migration Guidelines for further information.

    Utility module example

    // types.ts
    type GreetingModule = {
      getHello: () => string;
      getGoodbye: () => string;
    };
    
    // index.native.ts
    import { GreetingModule } from "./types";
    function getHello() {
      return "hello from mobile code";
    }
    function getGoodbye() {
      return "goodbye from mobile code";
    }
    const Greeting: GreetingModule = {
      getHello,
      getGoodbye,
    };
    export default Greeting;
    
    // index.ts
    import { GreetingModule } from "./types";
    function getHello() {
      return "hello from other platform code";
    }
    function getGoodbye() {
      return "goodbye from other platform code";
    }
    const Greeting: GreetingModule = {
      getHello,
      getGoodbye,
    };
    export default Greeting;

    Component module example

      // types.ts
      export type MyComponentProps = {
        foo: string;
      }
    
      // index.ios.ts
      import { MyComponentProps } from "./types";
    
      export MyComponentProps;
      export default function MyComponent({ foo }: MyComponentProps) { /* ios specific implementation */ }
    
      // index.ts
      import { MyComponentProps } from "./types";
    
      export MyComponentProps;
      export default function MyComponent({ foo }: MyComponentProps) { /* Default implementation */ }

  • 1.16 Reusable Types: Reusable type definitions, such as models (e.g., Report), must have their own file and be placed under src/types/. The type should be exported as a default export.

    // src/types/Report.ts
    
    type Report = {...};
    
    export default Report;

  • 1.17 tsx: Use .tsx extension for files that contain React syntax.

    Why? It is a widely adopted convention to mark any files that contain React specific syntax with .jsx or .tsx.

  • 1.18 No inline prop types: Do not define prop types inline for components that are exported.

    Why? Prop types might need to be exported from component files. If the component is only used inside a file or module and not exported, then inline prop types can be used.

    // BAD
    export default function MyComponent({ foo, bar }: { foo: string, bar: number }){
      // component implementation
    };
    
    // GOOD
    type MyComponentProps = { foo: string, bar: number };
    export default MyComponent({ foo, bar }: MyComponentProps){
      // component implementation
    }

  • 1.19 Satisfies Operator: Use the satisfies operator when you need to validate that the structure of an expression matches a specific type, without affecting the resulting type of the expression.

    Why? TypeScript developers often want to ensure that an expression aligns with a certain type, but they also want to retain the most specific type possible for inference purposes. The satisfies operator assists in doing both.

    // BAD
    const sizingStyles = {
      w50: {
          width: '50%',
      },
      mw100: {
          maxWidth: '100%',
      },
    } as const;
    
    // GOOD
    const sizingStyles = {
      w50: {
          width: '50%',
      },
      mw100: {
          maxWidth: '100%',
      },
    } satisfies Record<string, ViewStyle>;

  • 1.20 Hooks instead of HOCs: Replace HOCs usage with Hooks whenever possible.

    Why? Hooks are easier to use (can be used inside the function component), and don't need nesting or compose when exporting the component. It also allows us to remove compose completely in some components since it has been bringing up some issues with TypeScript. Read the compose usage section for further information about the TypeScript issues with compose.

    Note: Because Onyx doesn't provide a hook yet, in a component that accesses Onyx data with withOnyx HOC, please make sure that you don't use other HOCs (if applicable) to avoid HOC nesting.

    // BAD
    type ComponentOnyxProps = {
        session: OnyxEntry<Session>;
    };
    
    type ComponentProps = WindowDimensionsProps &
        WithLocalizeProps &
        ComponentOnyxProps & {
            someProp: string;
        };
    
    function Component({windowWidth, windowHeight, translate, session, someProp}: ComponentProps) {
        // component's code
    }
    
    export default compose(
        withWindowDimensions,
        withLocalize,
        withOnyx<ComponentProps, ComponentOnyxProps>({
            session: {
                key: ONYXKEYS.SESSION,
            },
        }),
    )(Component);
    
    // GOOD
    type ComponentOnyxProps = {
        session: OnyxEntry<Session>;
    };
    
    type ComponentProps = ComponentOnyxProps & {
        someProp: string;
    };
    
    function Component({session, someProp}: ComponentProps) {
        const {windowWidth, windowHeight} = useWindowDimensions();
        const {translate} = useLocalize();
        // component's code
    }
    
    // There is no hook alternative for withOnyx yet.
    export default withOnyx<ComponentProps, ComponentOnyxProps>({
        session: {
            key: ONYXKEYS.SESSION,
        },
    })(Component);

  • 1.21 compose usage: Avoid the usage of compose function to compose HOCs in TypeScript files. Use nesting instead.

    Why? compose function doesn't work well with TypeScript when dealing with several HOCs being used in a component, many times resulting in wrong types and errors. Instead, nesting can be used to allow a seamless use of multiple HOCs and result in a correct return type of the compoment. Also, you can use hooks instead of HOCs whenever possible to minimize or even remove the need of HOCs in the component.

    // BAD
    export default compose(
        withCurrentUserPersonalDetails,
        withReportOrNotFound(),
        withOnyx<ComponentProps, ComponentOnyxProps>({
            session: {
                key: ONYXKEYS.SESSION,
            },
        }),
    )(Component);
    
    // GOOD
    export default withCurrentUserPersonalDetails(
        withReportOrNotFound()(
            withOnyx<ComponentProps, ComponentOnyxProps>({
                session: {
                    key: ONYXKEYS.SESSION,
                },
            })(Component),
        ),
    );
    
    // GOOD - alternative to HOC nesting
    const ComponentWithOnyx = withOnyx<ComponentProps, ComponentOnyxProps>({
        session: {
            key: ONYXKEYS.SESSION,
        },
    })(Component);
    const ComponentWithReportOrNotFound = withReportOrNotFound()(ComponentWithOnyx);
    export default withCurrentUserPersonalDetails(ComponentWithReportOrNotFound);

  • 1.22 Type imports/exports: Always use the type keyword when importing/exporting types

    Why? In order to improve code clarity and consistency and reduce bundle size after typescript transpilation, we enforce the all type imports/exports to contain the type keyword. This way, TypeScript can automatically remove those imports from the transpiled JavaScript bundle

    Imports:

    // BAD
    import {SomeType} from './a'
    import someVariable from './a'
    
    import {someVariable, SomeOtherType} from './b'
    
    // GOOD
    import type {SomeType} from './a'
    import someVariable from './a'

    Exports:

    // BAD
    export {SomeType}
    export someVariable
    // or 
    export {someVariable, SomeOtherType}
    
    // GOOD
    export type {SomeType}
    export someVariable

Exception to Rules

Most of the rules are enforced in ESLint or checked by TypeScript. If you think your particular situation warrants an exception, post the context in the #expensify-open-source Slack channel with your message prefixed with TS EXCEPTION:. The internal engineer assigned to the PR should be the one that approves each exception, however all discussion regarding granting exceptions should happen in the public channel instead of the GitHub PR page so that the TS migration team can access them easily.

When an exception is granted, link the relevant Slack conversation in your PR. Suppress ESLint or TypeScript warnings/errors with comments if necessary.

This rule will apply until the migration is done. After the migration, discussion on granting exception can happen inside the PR page and doesn't need take place in the Slack channel.

Communication Items

Comment in the #expensify-open-source Slack channel if any of the following situations are encountered. Each comment should be prefixed with TS ATTENTION:. Internal engineers will access each situation and prescribe solutions to each case. Internal engineers should refer to general solutions to each situation that follows each list item.

  • I think types definitions in a third party library or JavaScript's built-in module are incomplete or incorrect

When the library indeed contains incorrect or missing type definitions and it cannot be updated, use module augmentation to correct them. All module augmentation code should be contained in /src/types/modules/*.d.ts, each library as a separate file.

// external-library-name.d.ts

declare module "external-library-name" {
  interface LibraryComponentProps {
    // Add or modify typings
    additionalProp: string;
  }
}

Migration Guidelines

This section contains instructions that are applicable during the migration.

  • 🚨 DO NOT write new code in TypeScript yet. The only time you write TypeScript code is when the file you're editing has already been migrated to TypeScript by the migration team, or when you need to add new files under src/libs, src/hooks, src/styles, and src/languages directories. This guideline will be updated once it's time for new code to be written in TypeScript. If you're doing a major overhaul or refactoring of particular features or utilities of App and you believe it might be beneficial to migrate relevant code to TypeScript as part of the refactoring, please ask in the #expensify-open-source channel about it (and prefix your message with TS ATTENTION:).

  • If you're migrating a module that doesn't have a default implementation (i.e. index.ts, e.g. getPlatform), convert index.website.js to index.ts. Without index.ts, TypeScript cannot get type information where the module is imported.

  • Deprecate the usage of underscore. Use vanilla methods from JS instead. Only use lodash when there is no easy vanilla alternative (eg. lodashMerge). eslint: no-restricted-imports

// BAD
var arr = [];
_.each(arr, () => {});

// GOOD
var arr = [];
arr.forEach(() => {});

// BAD
lodashGet(object, ['foo'], 'bar');

// GOOD
object?.foo ?? 'bar';
  • Found type bugs. Now what?

    If TypeScript migration uncovers a bug that has been “invisible,” there are two options an author of a migration PR can take:

    • Fix issues if they are minor. Document each fix in the PR comment.
    • Suppress a TypeScript error stemming from the bug with @ts-expect-error. Create a separate GH issue. Prefix the issue title with [TS ERROR #<issue-number-of-migration-PR>]. Cross-link the migration PR and the created GH issue. On the same line as @ts-expect-error, put down the GH issue number prefixed with TODO:.

    The @ts-expect-error annotation tells the TS compiler to ignore any errors in the line that follows it. However, if there's no error in the line, TypeScript will also raise an error.

    // @ts-expect-error TODO: #21647
    const x: number = "123"; // No TS error raised
    
    // @ts-expect-error
    const y: number = 123; // TS error: Unused '@ts-expect-error' directive.
  • The TS issue I'm working on is blocked by another TS issue because of type errors. What should I do?

    In order to proceed with the migration faster, we are now allowing the use of @ts-expect-error annotation to temporally suppress those errors and help you unblock your issues. The only requirements is that you MUST add the annotation with a comment explaining that it must be removed when the blocking issue is migrated, e.g.:

    return (
        <MenuItem
            // @ts-expect-error TODO: Remove this once MenuItem (https://github.com/Expensify/App/issues/25144) is migrated to TypeScript.
            wrapperStyle={styles.mr3}
            key={text}
            icon={icon}
            title={text}
            onPress={onPress}
        />
    );

    You will also need to reference the blocking issue in your PR. You can find all the TS issues here.

Learning Resources

Quickest way to learn TypeScript

Footnotes

  1. This is because skipLibCheck TypeScript configuration is set to true in this project.