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
satisfies
operator 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 const
operator 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,
satisfies
is 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.
updatedTransactionMeta
is widened toTransactionMeta
.- The error message enumerates all members of the
Exclude<TransactionStatus, TransactionStatus.failed>
union as the correct type forstatus
. - While this means that
updatedTransactionMeta
has been correctly narrowed to the first member in theTransactionMeta
discriminated 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.
updatedTransactionMeta
is narrowed to its most specific type signature.- The expected
status
property 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 error
Type 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
is
type 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 as
enables type coercions to structurally incompatible types. -
as unknown as
should 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 as
can 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 const
assertions. -
Key remapping in mapped types uses the
as
keyword.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-error
a safer alternative to type assertions by mitigating false positives. -
@ts-expect-error
usage 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-comment
rule, 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 any
only applies to a single instance of a single variable without propagating to other instances.
- Banned by the
@typescript-eslint/no-explicit-any
rule.
- 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 thi 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.
-
any
does not represent the widest type. In fact, it is not a type at all.any
is a compiler directive for disabling type checking for the value or type to which it's assigned. -
any
suppresses all error messages about its assignee.- The suppressed errors still affect the code, but
any
makes it impossible to assess and counteract their influence. any
has the same effect as going through the entire codebase to apply@ts-ignore
to every single instance of the target variable or type.- Much like type assertions, code with
any
usage 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
-
any
subsumes 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 anany
type or value becomes anany
type 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();
-
any
infects 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 toany
in@metamask/utils
export 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
any
s.The valid error messages shown in the comments are suppressed by the
any
types.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.
unknown
is the universal supertype i.e. the widest possible type, equivalent to the universal set(U).- Every type is assignable to
unknown
, butunknown
is not assignable to any type but itself. - When typing the assignee,
any
andunknown
are 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:
unknown
is only assignable tounknown
.- The type of the assigned must be a subtype of the assignee, but
unknown
can 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
never
itself is rarely the correct type, tryingnever
as a substitute forany
is 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/jest
v27. Jest v29 no longer usesany
as 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
any
in this context is not harmful in the same way that it is in other contexts, as theany
types 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
any
as a generic argument.Example (🔗 permalink):
🚫
// eslint-disable-next-line @typescript-eslint/no-explicit-any const controllerMessenger = ControllerMessenger<any, any>;
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.
Omit
can 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
,implements
are 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
Record
and have an index signature ofstring
by 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();
}