The TypeScript Guidelines establishes stylistic conventions and best practices for contributing TypeScript code to the MetaMask codebase.
This document is intended to complement linters and formatters. Emphasis is put on discussing underlying concepts and rationale, rather than listing rules and restrictions.
Type safety and maintainability are the highest priorities in these guidelines, even if that sometimes leads to unconventional or opinionated recommendations.
This document assumes that the reader has a high level of familiarity with TypeScript, and may omit explanations.
TypeScript provides a range of syntax for communicating type information with the compiler.
- The compiler performs type inference on all types and values in the code.
- The user can assign type annotations (
:,satisfies) to override inferred types or add type constraints. - The user can add type assertions (
as,!) to force the compiler to accept user-supplied types even if they contradict the inferred types. - Finally, there are escape hatches that let type checking be disabled (
@ts-expect-error,any) for a certain scope of code.
The order of this list represents the general order of preference for using these features.
TypeScript is very good at inferring types. Explicit type annotations and assertions are the exception rather than the rule in a well-managed TypeScript codebase.
Some fundamental type information must always be supplied by the user, such as function and class signatures, interfaces for interacting with external entities or data types, and types that express the domain model of the codebase.
However, for most types, inference should be preferred over annotations and assertions.
- Explicit type annotations (
:) and type assertions (as,!) prevent inference-based narrowing of the user-supplied types.- The compiler errs on the side of trusting user input, which prevents it from utilizing additional type information that it is able to infer.
- The
satisfiesoperator is an exception to this rule.
- Type inferences are responsive to changes in code, always reflecting up-to-date type information, while annotations and assertions rely on hard-coding, making them brittle against code drift.
- The
as constoperator can be used to narrow an inferred abstract type into a specific literal type. When used on an object or array, it applies to each element.
Enforcing a wider type defeats the purpose of adding an explicit type declaration, as it loses type information instead of adding it. Double-check that the declared type is narrower than the inferred type.
Example (🔗 permalink):
🚫 Type declarations
const name: string = 'METAMASK'; // Type 'string'
const chainId: string = this.messagingSystem(
'NetworkController:getProviderConfig',
).chainId; // Type 'string'
const BUILT_IN_NETWORKS = new Map<string, `0x${string}`>([
['mainnet', '0x1'],
['sepolia', '0xaa36a7'],
]); // Type 'Map<string, `0x${string}`>'✅ Type inferences
const name = 'METAMASK'; // Type 'METAMASK'
const chainId = this.messagingSystem(
'NetworkController:getProviderConfig',
).chainId; // Type '`0x${string}`'
const BUILT_IN_NETWORKS = {
mainnet: '0x1',
sepolia: '0xaa36a7',
} as const; // Type { readonly mainnet: '0x1'; readonly sepolia: '0xaa36a7'; }Example (🔗 permalink):
type TransactionMeta = TransactionBase &
(
| {
status: Exclude<TransactionStatus, TransactionStatus.failed>;
}
| {
status: TransactionStatus.failed;
error: TransactionError;
}
);
const updatedTransactionMeta = {
...transactionMeta,
status: TransactionStatus.rejected,
};
this.messagingSystem.publish(
`${controllerName}:transactionFinished`,
updatedTransactionMeta, // Expected type: 'TransactionMeta'
);
// Property 'error' is missing in type 'typeof updatedTransactionMeta' but required in type '{ status: TransactionStatus.failed; error: TransactionError; }'.ts(2345)🚫 Widen to TransactionMeta
Adding a type annotation does prevent the error above from being produced:
// Type 'TransactionMeta'
const updatedTransactionMeta: TransactionMeta = {
...transactionMeta,
status: TransactionStatus.rejected,
};✅ Narrow to the correct type signature
However, TransactionMeta is a discriminated union of two separate types — "not failed" and "failed" — and the property that acts as the discriminator is status. Instead of using TransactionMeta, which specifies that a error property could be present, it would be better to get TypeScript to infer the first of the two types ("not failed"), which guarantees that error is not present. We can do this by adding as const after TransactionStatus.rejected:
const updatedTransactionMeta = {
...transactionMeta,
status: TransactionStatus.rejected as const,
};An explicit type annotation may be used to override an inferred type if:
- It can further narrow the inferred type, supplying type information that the compiler cannot infer or does not have access to.
- It is being used to enforce a wider type constraint, not to assign a specific type definition. For this use case,
satisfiesis preferred over:.
Compared to type assertions, type annotations are more responsive to code drift. If the assignee's type becomes incompatible with the assigned type annotation, the compiler will raise a type error, whereas in most cases a type assertion will still suppress the error.
Introduced in TypeScript 4.9, the satisfies operator can be used to enforce a type constraint, while also allowing the compiler to fully narrow the assigned type through inference.
Example (🔗 permalink):
(continued from previous example)
🚫 Use a type annotation for type validation.
updatedTransactionMetais widened toTransactionMeta.- The error message enumerates all members of the
Exclude<TransactionStatus, TransactionStatus.failed>union as the correct type forstatus. - While this means that
updatedTransactionMetahas been correctly narrowed to the first member in theTransactionMetadiscriminated union, it is still not assigned the most specific type that could be inferred.
const updatedTransactionMeta: TransactionMeta = {
...transactionMeta,
status: TransactionStatus.rejected,
// Object literal may only specify known properties, and 'nonTransactionMetaProperty' does not exist in type 'TransactionMeta'.ts(1360)
nonTransactionMetaProperty: null,
};
// Property 'error' does not exist on type '{ status: TransactionStatus.approved | TransactionStatus.cancelled | TransactionStatus.confirmed | TransactionStatus.dropped | TransactionStatus.rejected | TransactionStatus.signed | TransactionStatus.submitted | TransactionStatus.unapproved; ... }'.(2339)
updatedTransactionMeta.error;✅ Use the satisfies operator for type validation.
updatedTransactionMetais narrowed to its most specific type signature.- The expected
statusproperty is not a union.
const updatedTransactionMeta = {
...transactionMeta,
status: TransactionStatus.rejected as const,
// Object literal may only specify known properties, and 'nonTransactionMetaProperty' does not exist in type 'TransactionMeta'.ts(1360)
nonTransactionMetaProperty: null,
} satisfies TransactionMeta;
// // Property 'error' does not exist on type '{ status: TransactionStatus.rejected; ... }'.(2339)
updatedTransactionMeta.error;This is a special case where type inference cannot be expected to reach a useful conclusion without user-provided information.
The compiler doesn't have any values to use for inferring a type, and it cannot arbitrarily restrict the range of types that could be inserted into the collection. Given these restrictions, it has to assume the widest type, which is often any.
It's up to the user to appropriately narrow down this type by adding an explicit annotation that provides information about the user's intentions.
Example (🔗 permalink):
🚫
const tokens = []; // Type 'any[]'
const tokensMap = new Map(); // Type 'Map<any, any>'✅
const tokens: string[] = []; // Type 'string[]'
const tokensMap = new Map<string, Token>(); // Type 'Map<string, Token>'The reason type inference and the satisfies operator are generally preferred over type annotations is that they provide us with the narrowest applicable type signature.
When typing an extensible data type, however, this becomes a liability, because the narrowest type signature by definition doesn't include any newly assigned properties or elements. Therefore, when declaring or instantiating an object, array, or class, explicitly assign a type annotation, unless it is intended to be immutable.
Example (🔗 permalink):
🚫 Type inference, satisfies operator
// const SUPPORTED_CHAIN_IDS: ("0x1" | "0x38" | "0xa" | "0x2105" | "0x89" | "0xa86a" | "0xa4b1" | "0xaa36a7" | "0xe708")[]
export const SUPPORTED_CHAIN_IDS = [ // inference
CHAIN_IDS.ARBITRUM,
CHAIN_IDS.AVALANCHE,
...
CHAIN_IDS.SEPOLIA,
];
export const SUPPORTED_CHAIN_IDS = [ // `satisfies` operator
...
] satisfies `0x${string}`[];
const { chainId } = networkController.state.providerConfig // Type of 'chainId': '`0x${string}`';
SUPPORTED_CHAIN_IDS.includes(chainId) // Argument of type '`0x${string}`' is not assignable to parameter of type '"0x1" | "0x38" | "0xa" | "0x2105" | "0x89" | "0xa86a" | "0xa4b1" | "0xaa36a7" | "0xe708"'.ts(2345)✅ Type annotation
export const SUPPORTED_CHAIN_IDS: `0x${string}`[] = [ // type annotation
...
];
const { chainId } = networkController.state.providerConfig // Type of 'chainId': '`0x${string}`';
SUPPORTED_CHAIN_IDS.includes(chainId) // No errorType assertions are inherently unsafe and should only be used if the accurate type is unreachable through other means.
-
Type assertions overwrite type-checked and compiler-inferred types with unverified user-supplied types.
-
Type assertions can be used to suppress valid compiler errors by asserting to an incorrect type.
-
Type assertions are erased at compile time without being validated against runtime code. If the type assertion is wrong, it will fail silently without generating an exception or null.
-
Type assertions make the codebase brittle against changes.
-
As changes accumulate in the codebase, type assertions may continue to enforce type assignments that have become incorrect, or keep silencing errors that have changed. This can cause dangerous silent failures.
-
Type assertions will also provide no indication when they become unnecessary or redundant due to changes in the code.
Example (🔗 permalink):
enum Direction { Up = 'up', Down = 'down', Left = 'left', Right = 'right', } const directions = Object.values(Direction); // Error: Element implicitly has an 'any' type because index expression is not of type 'number'.(7015) // Only one of the two `as` assertions necessary to fix error, but neither are flagged as redundant. for (const key of Object.keys(directions) as (keyof typeof directions)[]) { const direction = directions[key as keyof typeof directions]; }
-
Example (🔗 permalink):
type SomeInterface = { name: string; length: number };
type SomeOtherInterface = { value: boolean };
function isSomeInterface(x: unknown): x is SomeInterface {
return (
'name' in x &&
typeof x.name === 'string' &&
'length' in x &&
typeof x.length === 'number'
);
}🚫 Type assertion
function f(x: SomeInterface | SomeOtherInterface) {
console.log((x as SomeInterface).name);
}✅ Narrowing with type guard
function f(x: SomeInterface | SomeOtherInterface) {
if (isSomeInterface(x)) {
console.log(x.name); // Type of x: 'SomeInterface'. Type of x.name: 'string'.
}
}Example (🔗 permalink):
const nftMetadataResults = await Promise.allSettled(...);
nftMetadataResults
.filter((promise) => promise.status === 'fulfilled')
.forEach((elm) =>
this.updateNft(
elm.value.nft, // Property 'value' does not exist on type 'PromiseRejectedResult'.ts(2339)
...
),
);🚫 Type assertion
(nftMetadataResults.filter(
(promise) => promise.status === 'fulfilled',
) as { status: 'fulfilled'; value: NftUpdate }[])
.forEach((elm) =>
this.updateNft(
elm.value.nft,
...
),
);✅ Use a type guard as the predicate for the filter operation, enabling TypeScript to narrow the filtered results to PromiseFulfilledResult at the type level
nftMetadataResults.filter(
(result): result is PromiseFulfilledResult<NftUpdate> =>
result.status === 'fulfilled',
)
.forEach((elm) =>
this.updateNft(
elm.value.nft,
...
),
);Note: The
istype predicate in this example is unnecessary as of TypeScript v5.5.
Often, the compiler will tell us exactly what the target type for an assertion needs to be.
Example (🔗 permalink):
🚫 Compiler specifies that the target type should be keyof NftController
// Error: Argument of type '"getNftInformation"' is not assignable to parameter of type 'keyof NftController'.ts(2345)
// 'getNftInformation' is a private method of class 'NftController'
sinon.stub(nftController, 'getNftInformation');✅ as assertion to type specified by compiler
sinon.stub(nftController, 'getNftInformation' as keyof typeof nftController);Use as unknown as to force a type assertion to an incompatible type, or to perform runtime property access, assignment, or deletion
-
TypeScript only allows type assertions that narrow or widen a type. Type assertions that fall outside of this category generate the following error:
Error: Conversion of type 'string' to type 'number' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.(2352)
-
as unknown asenables type coercions to structurally incompatible types. -
as unknown asshould only be used as a last resort for a very good reason, and not as a convenient way to force types into incorrect shapes that will temporarily silence errors. -
as unknown ascan also resolve type errors arising from runtime property access, assignment, or deletion.
Example (🔗 permalink):
🚫 any
for (const key of getKnownPropertyNames(this.internalConfig)) {
(this as any)[key] = this.internalConfig[key];
}
delete addressBook[chainId as any];
// Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ [chainId: `0x${string}`]: { [address: string]: AddressBookEntry; }; }'.
// No index signature with a parameter of type 'string' was found on type '{ [chainId: `0x${string}`]: { [address: string]: AddressBookEntry; }; }'.ts(7053)✅ as unknown as
for (const key of getKnownPropertyNames(this.internalConfig)) {
(this as unknown as typeof this.internalConfig)[key] =
this.internalConfig[key];
}
delete addressBook[chainId as unknown as `0x${string}`];- With type assertions, we still get working intellisense, autocomplete, and other IDE and compiler features using the asserted type.
- Type assertions also provide an indication of what the author intends or expects the type to be.
- Even an assertion to a wrong type still allows the compiler to show us warnings and errors as the code changes.
-
A type assertion may be necessary to satisfy constraints. To be used safely, it must also be supported by runtime validations.
Example (🔗 permalink):
handle<Params extends JsonRpcParams, Result extends Json>( request: JsonRpcRequest<Params>, callback: (error: unknown, response: JsonRpcResponse<Result>) => void, ): void; handle<Params extends JsonRpcParams, Result extends Json>( requests: (JsonRpcRequest<Params> | JsonRpcNotification<Params>)[], callback: (error: unknown, responses: JsonRpcResponse<Result>[]) => void, ): void; handle<Params extends JsonRpcParams, Result extends Json>( requests: (JsonRpcRequest<Params> | JsonRpcNotification<Params>)[], ): Promise<JsonRpcResponse<Result>[]>;
✅
handle( req: | (JsonRpcRequest | JsonRpcNotification)[] | JsonRpcRequest | JsonRpcNotification, callback?: (error: unknown, response: never) => void, ) { ... if (Array.isArray(req) && callback) { return this.#handleBatch( req, // This assertion is safe because of the runtime checks validating that `req` is an array and `callback` is defined. // There is only one overload signature that satisfies both conditions, and its `callback` type is the one that's being asserted. callback as ( error: unknown, responses?: JsonRpcResponse<Json>[], ) => void, ); } ... }
-
A type assertion may be necessary to align with a type which is verified to be accurate by an external source of truth. To be used safely, it must also be supported by runtime validations.
Example (🔗 permalink):
✅
import contractMap from '@metamask/contract-metadata'; type LegacyToken = { name: string; logo: `${string}.svg`; symbol: string; decimals: number; erc20?: boolean; erc721?: boolean; }; export const STATIC_MAINNET_TOKEN_LIST = Object.entries( // This type assertion is to the known schema of the JSON object `contractMap`. contractMap as Record<Hex, LegacyToken>, ).reduce((acc, [base, contract]) => { const { name, symbol, decimals, logo, erc20, erc721 } = contract; // The required properties are validated at runtime if ([name, symbol, decimals, logo].some((e) => !e)) { return; } ... }, {});
-
Rarely, a type assertion may be necessary to resolve or suppress a type error caused by a bug or limitation of an external library, or even the TypeScript language itself.
Example (🔗 permalink):
✅
import { produceWithPatches } from 'immer'; protected update( callback: (state: Draft<ControllerState>) => void | ControllerState, ): { nextState: ControllerState; patches: Patch[]; inversePatches: Patch[]; } { // We run into ts2589, "infinite type depth", if we don't assert `produceWithPatches` here. const [nextState, patches, inversePatches] = ( produceWithPatches as unknown as ( state: ControllerState, cb: typeof callback, ) => [ControllerState, Patch[], Patch[]] )(this.#internalState, callback); ... }
-
as constassertions. -
Key remapping in mapped types uses the
askeyword.Example (🔗 permalink):
type MappedTypeWithNewProperties<Type> = { [Properties in keyof Type as NewKeyType]: Type[Properties]; };
TypeScript provides several escape hatches that disable compiler type checks altogether and suppress compiler errors. Using these to ignore typing issues is dangerous and reduces the effectiveness of TypeScript.
-
@ts-expect-error-
Applies to a single line, which may contain multiple variables and errors.
-
It alerts users if an error it was suppressing is resolved by changes in the code:
Error: Unused '@ts-expect-error' directive.
This feature makes
@ts-expect-errora safer alternative to type assertions by mitigating false positives. -
@ts-expect-errorusage should generally be reserved to situations where an error is the intended or expected result of an operation, not to silence errors when the correct typing solution is difficult to find. -
Allowed by the
@typescript-eslint/ban-ts-commentrule, although a description comment is required.
-
-
any- Applies to all instances of the target variable or type throughout the entire codebase, and in downstream code as well.
as anyonly applies to a single instance of a single variable without propagating to other instances.
- Banned by the
@typescript-eslint/no-explicit-anyrule.
- Applies to all instances of the target variable or type throughout the entire codebase, and in downstream code as well.
Sometimes, there is a need to force a branch to execute at runtime for security or testing purposes, even though that branch has correctly been inferred as being inaccessible by the TypeScript compiler.
This is often the case when downstream consumers of the code are using JavaScript and do not have access to compile-time guardrails.
Example (🔗 permalink):
🚫
Error: This comparison appears to be unintentional because the types '`0x${string}`' and '"__proto__"' have no overlap.ts(2367)
function exampleFunction(chainId: `0x${string}`) {
if (chainId === '__proto__') {
return;
}
...
}🚫
Error: Argument of type '"__proto__"' is not assignable to parameter of type '`0x${string}`'.ts(2345)
exampleFunction('__proto__');✅
function exampleFunction(chainId: `0x${string}`) {
// @ts-expect-error Suppressing to perform runtime check
if (chainId === '__proto__') {
return;
}
...
}✅
// @ts-expect-error Suppressing to perform runtime check
exampleFunction('__proto__');Example (🔗 permalink):
✅
// @ts-expect-error Suppressing to test runtime error handling
// @ts-expect-error Intentionally testing invalid state
// @ts-expect-error We are intentionally passing bad input.If accompanied by a TODO comment, @ts-expect-error is acceptable to use for marking errors that have clear plans of being resolved
Example (🔗 permalink):
✅
// @ts-expect-error TODO: remove this annotation once the `Eip1193Provider` class is released, resolving this provider misalignment issue.
return new Web3Provider(provider);
// TODO: Fix this by handling or eliminating the undefined case
// @ts-expect-error This variable can be `undefined`, which would break here.This recommendation applies to any disruptive change that creates many errors at once (e.g. dependency update, upstream refactor, package migration).
See this entry in the core repo "package migration process guide," which recommends that complex or blocked errors should be annotated with a // @ts-expect-error TODO: comment, and then revisited once the disruptive change has been completed.
any is the most dangerous form of explicit type declaration, and should be completely avoided.
Unfortunately, when confronted with nontrivial typing issues, there's a very strong incentive to use any to bypass the TypeScript type system.
It's very easy for teams to fall into a pattern of unblocking feature development using any, with the intention of fixing it later. This is a major source of tech debt, and the destructive influence of any usage on the type safety of a codebase cannot be understated.
To prevent any instances from being introduced into the codebase, it is not enough to rely on the @typescript-eslint/no-explicit-any ESLint rule. It's also necessary for all contributors to share a common understanding of exactly why any is dangerous, and how it can be avoided.
-
anydoes not represent the widest type. In fact, it is not a type at all.anyis a compiler directive for disabling type checking for the value or type to which it's assigned. -
anysuppresses all error messages about its assignee.- The suppressed errors still affect the code, but
anymakes it impossible to assess and counteract their influence. anyhas the same effect as going through the entire codebase to apply@ts-ignoreto every single instance of the target variable or type.- Much like type assertions, code with
anyusage becomes brittle against changes, since the compiler is unable to update its feedback even if the suppressed error has been altered, or entirely new type errors have been added.
- The suppressed errors still affect the code, but
-
anysubsumes all other types it comes into contact with. Any type that is in a union, intersection, is a property of, or has any other relationship with ananytype or value becomes ananytype itself. This represents an unmitigated loss of type information.Example (🔗 permalink):
// Type of 'payload_0': 'any' const handler: | ((payload_0: ComposableControllerState, payload_1: Patch[]) => void) | ((payload_0: any, payload_1: Patch[]) => void); function returnsAny(): any { return { a: 1, b: true, c: 'c' }; } // Types of a, b, c are all `any` const { a, b, c } = returnsAny();
-
anyinfects all surrounding and downstream code with its directive to suppress errors. This is the most dangerous characteristic ofany, as it causes the encroachment of unsafe code that have no guarantees about type safety or runtime behavior.Example (🔗 permalink):
🚫 A single type,
InferWithParams, is set toanyin@metamask/utilsexport declare type InferWithParams< Type extends Struct<any>, Params extends JsonRpcParams, > = any; export declare type JsonRpcRequest< Params extends JsonRpcParams = JsonRpcParams, > = InferWithParams<typeof JsonRpcRequestStruct, Params>; // Resolves to 'any' export declare type JsonRpcResponse<Result extends Json> = | JsonRpcSuccess<Result> | JsonRpcFailure; // Resolves to 'any'
🚫 A downstream package is polluted with a large number of
anys.The valid error messages shown in the comments are suppressed by the
anytypes.import type { JsonRpcRequest, JsonRpcResponse } from '@metamask/utils' function sendMetadataHandler<Params extends JsonRpcParams, Result extends Json>( req: JsonRpcRequest<Params> // any, res: JsonRpcResponse<Result> // any, _next: JsonRpcEngineNextCallback, end: JsonRpcEngineEndCallback, { addSubjectMetadata, subjectType }: SendMetadataOptionsType, ): void { // Error: Property 'origin' does not exist on type 'JsonRpcRequest<Params>'.ts(2339) const { origin, params } = req; // 'any' , 'any' if (params && typeof params === 'object' && !Array.isArray(params)) { const { icon = null, name = null, ...remainingParams } = params; addSubjectMetadata({ ...remainingParams // 'any', iconUrl: icon // 'any', name, subjectType, origin, }); } else { return end(ethErrors.rpc.invalidParams({ data: params })); // 'any' } // Error: Property 'result' does not exist on type 'JsonRpcResponse<Result>'. // Property 'result' does not exist on type '{ error: JsonRpcError; id: string | number; jsonrpc: "2.0"; }'.ts(2339) res.result = true; // `res`, `res.result` are both 'any' return end(); }
All of this makes any a prominent cause of dangerous silent failures, where the code fails at runtime but the compiler does not provide any prior warning, which defeats the purpose of using a statically-typed language.
If any is being used as the assignee type, try unknown first, and then narrowing to an appropriate supertype of the assigned type
any usage is often motivated by a need to find a placeholder type that could be anything. unknown is a likely type-safe substitute for any in these cases.
unknownis the universal supertype i.e. the widest possible type, equivalent to the universal set(U).- Every type is assignable to
unknown, butunknownis not assignable to any type but itself. - When typing the assignee,
anyandunknownare completely interchangeable since every type is assignable to both.
Example (🔗 permalink):
🚫 any
type ExampleFunction = () => any;
const exampleArray: any[] = ['a', 1, true];✅ unknown
type ExampleFunction = () => unknown;
const exampleArray: unknown[] = ['a', 1, true];If any is being used as the assigned type, try never first, and then widening to an appropriate subtype of the assignee type
Unfortunately, when typing the assigned type, unknown cannot substitute any in most cases, because:
unknownis only assignable tounknown.- The type of the assigned must be a subtype of the assignee, but
unknowncan only be a subtype ofunknown.
However, never is assignable to all types.
Example (🔗 permalink):
function f1(arg1: string) { ... }🚫 any
In the function call f1(arg2), the argument arg2 is the assigned type and the parameter arg1 is the assignee type.
function f2(arg2: any) {
f1(arg2);
}🚫 unknown
Error: Argument of type 'unknown' is not assignable to parameter of type 'string'.(2345)
function f2(arg2: unknown) {
f1(arg2); // Error
}✅ never
Note: While
neveritself is rarely the correct type, tryingneveras a substitute foranyis a useful test.
The fact that never works while unknown doesn't is a very useful piece of information that lets us narrow down the search space to subtypes of the assignee type.
In this case, that means arg2 could be widened to any type that is a subtype of string.
function f2(arg2: never) {
f1(arg2); // No error
}✅ Subtype of string, the assignee type
function f2(arg2: `0x${string}`) {
f1(arg2); // No error
}Some generic types use any as a generic parameter default. If not actively avoided, this can silently introduce an any type into the code, causing unexpected behavior and suppressing useful errors.
Example (🔗 permalink):
🚫
const mockGetNetworkConfigurationByNetworkClientId = jest.fn(); // Type 'jest.Mock<any, any>'
mockGetNetworkConfigurationByNetworkClientId.mockImplementation(
(origin, type) => {},
); // No error!
// Even though 'mockImplementation' should only accept callbacks with a signature of '(networkClientId: string) => NetworkConfiguration | undefined'✅
const mockGetNetworkConfigurationByNetworkClientId = jest.fn<
ReturnType<NetworkController['getNetworkConfigurationByNetworkClientId']>,
Parameters<NetworkController['getNetworkConfigurationByNetworkClientId']>
>(); // Type 'jest.Mock<NetworkConfiguration | undefined, [networkClientId: string]>'
mockGetNetworkConfigurationByNetworkClientId.mockImplementation(
(origin, type) => {},
);
// Argument of type '(origin: any, type: any) => void' is not assignable to parameter of type '(networkClientId: string) => NetworkConfiguration | undefined'.
// Target signature provides too few arguments. Expected 2 or more, but got 1.ts(2345)Note: This is an issue with
@types/jestv27. Jest v29 no longer usesanyas the default type for its generic parameters.
Example (🔗 permalink):
✅ messenger is not polluted by any
class BaseController<
...,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
messenger extends RestrictedControllerMessenger<N, any, any, string, string>
> ...✅ ComposableControllerState is not polluted by any
export class ComposableController<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ComposableControllerState extends { [name: string]: Record<string, any> },
> extends BaseController<
typeof controllerName,
// (type parameter) ComposableControllerState in ComposableController<ComposableControllerState extends ComposableControllerStateConstraint>
ComposableControllerState,
ComposableControllerMessenger<ComposableControllerState>
>-
In general, using
anyin this context is not harmful in the same way that it is in other contexts, as theanytypes only are not directly assigned to any specific variable, and only function as constraints. -
More specific constraints provide better type safety and intellisense, and should be preferred wherever possible.
-
This only applies to generic constraints. It does not apply to passing in
anyas a generic argument.Example (🔗 permalink):
🚫
// eslint-disable-next-line @typescript-eslint/no-explicit-any const controllerMessenger = ControllerMessenger<any, any>;
If package a imports only types from b, should b be a dev or production dependency of a?
This depends on whether types from b are imported in the published .d.ts files of a.
This occurs if e.g. a exports a function that uses a type from b in its signature.
See the TypeScript handbook on this topic for more details.
It may not be enough just to have a type or a function take another type — you might have to constrain it if it's not allowed to be anything (e.g. extends Json)
// before
function createExampleMiddleware<Params, Result>(exampleParam);
// after
function createExampleMiddleware<
Params extends JsonRpcParams,
Result extends Json,
>(exampleParam);Omit<T, K> takes two generic types: T representing the original object type and K representing the property keys you want to remove. It returns a new type that has all the properties of T except for the ones specified in K. Here are some cases to use omit:
- Removing Unnecessary Properties:
Imagine you have a user interface with optional email and phone number fields. However, your API call only cares about the
username. You can use Omit to reduce the required properties:
interface User {
username: string;
email?: string;
phoneNumber?: string;
}
// Type for API call payload
type ApiPayload = Omit<User, 'email' | 'phoneNumber'>;
const payload: ApiPayload = { username: 'johndoe' };
// Now `payload` only has the `username` property, satisfying the API requirements.- Conditional Omission:
Sometimes, you might want to remove properties based on a condition.
Omitcan still be helpful:
interface CartItem {
productId: number;
quantity: number;
color?: string; // Optional color
// Omit color if quantity is 1
const singleItemPayload = Omit<CartItem, "color" extends string ? "color" : never>;
// Omit color for all items if quantity is always 1
const cartPayload: singleItemPayload[] = [];We enforce consistent and exclusive usage of type aliases over the interface keyword to declare types for several reasons:
- The capabilities of type aliases is a strict superset of those of interfaces.
- Crucially,
extends,implementsare also supported by type aliases. - Declaration merging is the only exception, but we have no use case for this feature that cannot be substituted by using type intersections.
- Crucially,
- Unlike interfaces, type aliases extend
Recordand have an index signature ofstringby default, which makes them compatible with our Json-serializable types (most notablyRecord<string, Json>). - Type aliases can be freely merged using the intersection (
&) operator, like interfaces which can implement multiple inheritance.
The implements keyword enables us to define and enforce interfaces, i.e. strict contracts consisting of expected object and class properties and abstract method signatures.
Writing an interface to establish the specifications of a class that external code can interact while without being aware of internal implementation details is encouraged as sound OOP development practice.
Here's an abbreviated example from @metamask/polling-controller of an interface being used to define one of our most important constructs.
export type IPollingController = {
...
}
export function AbstractPollingControllerBaseMixin<TBase extends Constructor>(
Base: TBase,
) {
abstract class AbstractPollingControllerBase
extends Base
implements IPollingController
{ ... }
return AbstractPollingControllerBase
}The concept of the interface as discussed in this section is not to be confused with interface syntax as opposed to type alias syntax. Note that in the above example, the IPollingController interface is defined as a type alias, not using the interface keyword.
TypeScript offers several tools for crafting clear data definitions, with enumerations and unions standing as popular choices.
Inevitably you will want to refer to the values of a union type somewhere (perhaps as the argument to a function). You can of course just use a literal which represents a member of that union — but if you have an enum, then all of the values are special, and any time you use a value then anyone can see where that value comes from.
🚫
type UserRole = 'admin' | 'editor' | 'subscriber';✅
enum AccountType {
Admin = 'admin',
User = 'user',
Guest = 'guest',
}Numeric enums are misleading because it creates a reverse mapping from value to property name, and when using Object.values to access member names, it will return the numerical values instead of the member names, potentially causing unexpected behavior.
🚫
enum Direction {
Up = 0,
Down = 1,
Left = 2,
Right = 3,
}
const directions = Object.values(Direction); // [0, 1, 2, 3]✅
enum Direction {
Up = 'Up',
Down = 'Down',
Left = 'Left',
Right = 'Right',
}
const directions = Object.values(Direction); // ["Up", "Down", "Left", "Right"]Although TypeScript is capable of inferring return types, adding them explicitly makes it much easier for the reader to see the API from the code alone and prevents unexpected changes to the API from emerging.
Example (🔗 permalink):
🚫
async function removeAccount(address: Hex) {
const keyring = await this.getKeyringForAccount(address);
if (!keyring.removeAccount) {
throw new Error(KeyringControllerError.UnsupportedRemoveAccount);
}
keyring.removeAccount(address);
this.emit('removedAccount', address);
await this.persistAllKeyrings();
return this.fullUpdate();
}✅
async function removeAccount(address: Hex): Promise<KeyringControllerState> {
const keyring = await this.getKeyringForAccount(address);
if (!keyring.removeAccount) {
throw new Error(KeyringControllerError.UnsupportedRemoveAccount);
}
keyring.removeAccount(address);
this.emit('removedAccount', address);
await this.persistAllKeyrings();
return this.fullUpdate();
}For selector functions, define the input state argument with the narrowest type that preserves functionality
A selector function that directly queries state properties should define its input state argument as a subtype of root state that only contains the required queried properties.
Example (🔗 permalink):
🚫
const selectTodos = (state: RootState) => {
const {
todoSlice: { todosA, todosB },
} = state;
return { todosA, todosB };
};✅
const selectTodos = (state: {
todoSlice: Pick<RootState['todoSlice'], 'todosA' | 'todosB'>;
}) => {
const {
todoSlice: { todosA, todosB },
} = state;
return { todosA, todosB };
};A selector function that is derived via composition of input selectors should ensure that the input state argument of the output function is defined by merging the input selectors' state argument types.
Tip
This is the default behavior of the reselect library, so there is no need to explicitly define a merged type for the result function when using the createSelector or createDeepEqualSelector methods.
Example (🔗 permalink):
🚫
const selectPropA = (state: RootState) => state.sliceA.propA;
const selectPropB = (state: RootState) => state.sliceB.propB;
// As its first argument, `selectResult` expects a state object of type `RootState`
const selectResult =
createSelector([selectPropA, selectPropB], (propA, propB) => ...);✅
const selectPropA = (state: { sliceA: { propA: string } })
=> state.sliceA.propA;
const selectPropB = (state: { sliceB: { propB: number } })
=> state.sliceA.propB;
// As its first argument, `selectResult` expects a state object of the following type:
// `{ sliceA: { propA: string } } & { sliceB: { propB: number } }`
const selectResult =
createSelector([selectPropA, selectPropB], (propA, propB) => ...);Selectors must be both composable and atomic. If all input selectors are typed homogeneously, these can become conflicting objectives.
- Composable:
Without heterogeneous typing for selectors, the size of the state type for all selectors tends to inflate. This is because a selector's state must be a supertype of the intersection of all input selector state types, including selectors that are nested in the definitions of input selectors.
Eventually, many selectors end up being defined with a state type that is close to or equal to the entire Redux state.
Paradoxically, selectors that only need access to very few properties end up needing to have the widest state type, because they tend to be merged into the most selectors across several nested levels.
- Atomic:
When selectors are actually invoked, including in test files, it's not always practical to prepare and pass in a very large state object.
It's both safer and more convenient to restrict the state argument type of the selector to the minimum size required for the selector to function. Note that this does not prevent the selector from accepting a larger state object, but it does allow the selector to function with an incomplete state object, as long as it satisfies the input argument type.
This requirement becomes incompatible with the composability requirement if all selectors must share a homogeneous state type. Enabling composed selectors to accept different, even disjoint state types resolves this issue.
Note
At runtime, all selectors are passed the entire Redux state, which is always assignable to the narrower state argument type.
Following this guideline does not affect selector memoization. When background state updates are dispatched to the Redux store, the Redux state object is shallow copied. This does not mutate the references of nested composite data structures, which makes cache invalidation a non-concern.
The only exception to this is an idempotent selector that returns the entire Redux state (e.g. (state: RootState) => state). This pattern should be avoided if possible, as it will cause a performance hit to any downstream selector.