Type annotations
-
Follow the guidelines outlined in React TypeScript Cheatsheets (opens in a new tab).
-
Always annotate your function parameters with types. This makes it easier to understand what the function does, and explicitly expresses the function's contracts.
// Prefer const myFunction = (a: number, b: number) => { ... } // Over const myFunction = (a, b) => { ... }
-
Rely on TypeScript's type inference for things like variable and array initialization, and in some cases, function return types. The goal of the type system is not to annotate every single variable with a type, but rather to make sure that the important parts of your code are type-safe. Read more about type inference here (opens in a new tab).
// Good examples of letting TypeScript infer types const numbers = [1, 2, 3]; // TypeScript infers number[] const user = { name: "John", age: 30 }; // TypeScript infers { name: string; age: number } // Function return type can often be inferred const addNumbers = (a: number, b: number) => a + b; // Return type number is inferred // However, explicitly type the parameters and return type for public APIs function calculateTotal(items: number[]): number { return items.reduce((sum, item) => sum + item, 0); }
-
Both TypeScript
interface
andtype
aliases can be extended, but they have different characteristics:interface
supports declaration merging, allowing you to add new fields to an existing interfacetype
aliases are more flexible for complex types and unions- Both can be extended, but with different syntax:
// Interface extension interface Animal { name: string; } interface Dog extends Animal { bark(): void; } // Type extension type Animal = { name: string; } type Dog = Animal & { bark(): void; } // Interface declaration merging (only possible with interfaces) interface User { name: string; } interface User { age: number; } // Results in User having both name and age // Complex types are often clearer with type aliases type Status = "loading" | "error" | "success"; type NumberOrString = number | string;
-
Choose between them based on your needs:
- Use
interface
when:- You're defining object shapes that might need declaration merging
- You're creating public APIs that others might need to extend
- Use
type
when:- You need to create union or intersection types
- You're working with complex types that combine multiple types
- You want to ensure no one can add fields through declaration merging
- Use
-
Don't use
any
unless you absolutely have to. Usingany
completely bails out of the TypeScript type system, and is the source of many bugs. Instead, useunknown
ornever
to express the fact that you don't know the type of a variable or that a function never returns.// Bad - using 'any' loses all type safety function processData(data: any) { data.nonExistentMethod(); // No TypeScript errors, but will fail at runtime } // Better - using 'unknown' requires type checking function processData(data: unknown) { if (typeof data === 'string') { return data.toUpperCase(); // OK - we've verified it's a string } if (Array.isArray(data)) { return data.length; // OK - we've verified it's an array } throw new Error('Unsupported data type'); } // Use 'never' for functions that never return function throwError(message: string): never { throw new Error(message); } // Use 'never' for impossible cases in exhaustive checks type Shape = Circle | Square; function getArea(shape: Shape) { if ('radius' in shape) { return Math.PI * shape.radius ** 2; } if ('width' in shape) { return shape.width ** 2; } // TypeScript will ensure we've handled all cases const exhaustiveCheck: never = shape; } // If you must use 'any', consider using 'unknown' with type assertions function legacyCode(data: unknown) { // Better than 'any' because it's explicit about the type assertion const userInput = data as string; return userInput.toLowerCase(); }
-
Wherever possible, use the
import type
syntax when importing types. This prevents the type from being imported at runtime, which reduces the bundle size. For example:// Prefer import type { User } from "@openmrs/esm-user-management"; // Instead of import { User } from "@openmrs/esm-user-management";
-
Prefer union types over status enums (opens in a new tab). Union types provide better type safety, don't generate runtime code, and are more idiomatic in TypeScript. They also make it impossible to assign invalid values, whereas enums can sometimes lead to unexpected behavior with numeric values or when used with JavaScript's type coercion.
// Prefer this type Status = "loading" | "error" | "success"; let status: Status = "loading"; // Only these three strings are allowed // Over this enum Status { Loading, Error, Success } let status = Status.Loading; // Compiles to a number (0) at runtime
-
Use the
jest.mocked
utility to preserve type information when mocking functions in tests. For example:Prefer:
const mockedShowSnackbar = jest.mocked(showSnackbar); // All the type information is preserved
Over:
const mockedShowSnackbar = showSnackbar as jest.Mock;
-
Use TypeScript's built-in utility types when possible. Common examples include:
// Make all properties optional type PartialUser = Partial<User>; // Make all properties required type RequiredUser = Required<User>; // Pick specific properties type UserName = Pick<User, 'firstName' | 'lastName'>; // Omit specific properties type UserWithoutPassword = Omit<User, 'password'>; // Extract return type from a function type ReturnValue = ReturnType<typeof myFunction>;
-
Prefer using
const assertions
for literal types:// This array has type readonly ["error", "success", "loading"] const statusLiterals = ["error", "success", "loading"] as const; type Status = typeof statusLiterals[number]; // "error" | "success" | "loading"
-
Prefer explicit prop types for React components:
// Prefer interface ButtonProps { variant: 'primary' | 'secondary'; onClick: () => void; children: React.ReactNode; } // Over const Button = (props: any) => { ... }