This article is published in English.
TypeScript Conventions That Keep Code Strict and Flexible
Practical TypeScript opinions: strict mode, unknown over any, sparse assertions, string unions, type vs interface, JSDoc props, utilities, and discriminated unions.
Good TypeScript should reduce the chance of incorrect code shipping without turning every file into ceremony. The conventions below favor simpler, safer, maintainable shapes while keeping the flexibility that makes TypeScript useful day to day.
TL;DR
- Spell out contracts at module and component edges.
- Allow the checker to infer private helpers and locals.
- Keep suppressions tiny and intentional.
- Borrow prior aliases and handbook utilities before inventing new ones.
- Favor literal unions and tagged variants when states are alternatives.
Enable strict mode
Always turn on strict checking in tsconfig.json (handbook):
{
"compilerOptions": {
"strict": true
}
}
Strict options catch common mistakes earlier and keep a higher baseline of correctness across a growing codebase.
Avoid the any type
When a value’s type is uncertain, prefer unknown over any so usage requires narrowing first:
// bad
function processValue(value: any) {
value.toLowerCase(); // No error, but might crash at runtime
}
// good
function processValue(value: unknown) {
if (typeof value === 'string') {
value.toLowerCase(); // OK, we've verified it’s a string
}
}
Ignoring type-checks
When a bypass is truly required, keep the escape hatch as small as possible.
Casting a single expression with as any often beats @ts-ignore, because the cast affects only that expression while @ts-ignore can silence an entire following line.
// third-party library with incomplete type definitions
import { someExternalLib } from 'external-lib';
const result = (someExternalLib.complexMethod() as any).undocumentedProperty;
In first-party code, prefer fixing types, adding a guard, or validating external data (for example with Zod) instead of silencing the checker.
Use @ts-expect-error when you know more than the checker can prove:
- Unlike
@ts-ignore, compilation fails if the error disappears unexpectedly. - That keeps intentional suppressions honest in review.
<div
style={{
// @ts-expect-error — vars are valid CSS properties
'--size': `${dimensions.size}px`,
}}
/>
Use type assertions sparingly
as assertions override the checker. Prefer runtime confirmation when possible:
// bad
const myCanvas = document.getElementById('canvas') as HTMLCanvasElement;
// good
const myCanvas = document.getElementById('canvas');
if (myCanvas instanceof HTMLCanvasElement) {
const ctx = myCanvas.getContext('2d');
}
Prefer type guards over assertions
Guards narrow only after a runtime check succeeds—fail-secure compared with a naked as:
type User = {
id: string;
name: string;
};
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
typeof value.id === 'string' &&
'name' in value &&
typeof value.name === 'string'
);
}
function greet(value: unknown) {
if (isUser(value)) {
// value is User here - no assertion needed
console.log(`Hello, ${value.name}`);
}
}
Use an assertion only when a runtime check is impossible or useless—typically when third-party props disagree with the surrounding form pattern:
<OTPInput
// Our form pattern expects a traditional change handler but this third-party
// component provides the text value as its only callback argument. We need
// to manually create an event-like object so validation works as expected
// for consumers.
onChange={(value) => {
props.onChange?.({
target: { value },
} as ChangeEvent<HTMLInputElement>);
}}
/>
Prefer string literal unions over enums
For application code, string literal unions usually beat enums: no runtime object, simpler emits, and friendlier for serialization:
// bad
enum Environments {
DEV = 'dev',
TESTING = 'test',
STAGING = 'staging',
PRODUCTION = 'prod',
}
// good
type Environments = 'dev' | 'test' | 'staging' | 'prod';
Keep enums when a runtime object is required, an existing API already uses enums, or a team standard demands them.
Infer unions from values
When values already exist at runtime, derive the union from one source of truth:
const ENVIRONMENT_META = {
dev: {},
test: {},
staging: {},
prod: {},
};
type Environments = keyof typeof ENVIRONMENT_META;
// ^? 'dev' | 'test' | 'staging' | 'prod'
Array form:
const ENVIRONMENTS = ['dev', 'test', 'staging', 'prod'] as const;
type Environments = (typeof ENVIRONMENTS)[number];
// ^? 'dev' | 'test' | 'staging' | 'prod'
A const assertion is required so the array stays a tuple of literals rather than string[].
Prefer type over interface
For most app code, type covers object shapes plus unions, intersections, mapped and conditional types. It also cannot be reopened for accidental merging:
// when required
interface Person {
name: string;
email: string;
}
// preferred
type Person = {
name: string;
email: string;
};
Defaulting to one form reduces needless switching between overlapping features.
Use interface only when its features matter
Public libraries may export interface deliberately so consumers can augment:
// library
export interface Theme {
colors: Record<string, string>;
}
// consumer: extend the interface without modifying the original source
declare module 'my-library' {
interface Theme {
spacing: Record<string, number>;
}
}
Document component props
Use JSDoc (/** ... */) on public or non-obvious props—purpose, behavior, constraints—while context is fresh:
type SearchFieldProps = {
/**
* A unique identifier for the field. Consider [useId](https://react.dev/reference/react/useId) to generate a unique ID.
*/
id?: string;
/**
* The label of the field. For "visually hidden" labels, use the `aria-label`
* attribute.
*/
label?: string;
/**
* @deprecated Use `label` instead.
*/
title?: string;
/**
* Handler that is called when the field is submitted.
*/
onSubmit?: (value: string) => void;
/**
* Handler that is called when the clear button, or <Escape> key, is pressed.
*/
onClear?: () => void;
} & AriaLabellingProps;
Documentation exceptions
Skip heavy docs when:
- The component is used once and unlikely to return
- It is a private child of a documented parent whose contract already explains behavior
Prefer clarity when unsure, and stay consistent.
Reuse existing types
Make public APIs explicit; let inference handle internals. Before inventing a new abstraction, look for an existing type, property, or utility to reuse so related code stays aligned.
Accessing properties
Indexed access pulls a property type from an existing alias:
import type { ThingProps } from '../thing';
type SpecialThingProps = {
...
label?: ThingProps['label'];
type?: ThingProps['type'];
};
function SpecialThing(props: SpecialThingProps) {}
Prefer intersections for composition
Combine types without duplicating fields (and their docs):
type Person = { name: string; email: string };
type User = Person & { id: string };
// ^? { name: string; email: string; id: string }
import type { ThingProps } from '../thing';
type SpecialThingProps = {
...
} & Pick<ThingProps, 'label' | 'type'>;
function SpecialThing(props: SpecialThingProps) {}
Infer implementation details
Inference keeps internal types synced with values:
function initContext() {
return {
name: 'Jane Doe',
email: 'jane.doe@example.com',
};
}
type Context = ReturnType<typeof initContext>;
// ^? { name: string; email: string }
ComponentProps derives props from a React component when the library does not export them:
import type { ComponentProps } from 'react';
import { RouterProvider } from 'react-aria-components';
type RouterProviderProps = ComponentProps<typeof RouterProvider>;
// {
// navigate: (path: Href, routerOptions: RouterOptions | undefined) => void;
// useHref?: (href: Href) => string;
// children: ReactNode;
// }
Parameters yields an argument tuple you can index:
// library code
declare function someLibFn(
value: number,
options: {
minimumFractionDigits: number;
maximumFractionDigits: number;
}
): void;
// consumer code
import { someLibFn } from 'some-lib';
type LibFnValue = Parameters<typeof someLibFn>[0];
// ^? number
type LibFnOptions = Parameters<typeof someLibFn>[1];
// ^? { minimumFractionDigits: number, maximumFractionDigits: number }
Reach for built-in utility types
Utility types cover common transforms and stay familiar to new teammates:
type User = {
id: string;
name: string;
email: string;
};
type UserPatch = Partial<User>;
type UserDetails = Omit<User, 'id'>;
type UserIdentity = Pick<User, 'id' | 'email'>;
Write a custom utility only when the transform names a real domain concept, not merely to save keystrokes.
Use discriminated unions
Tagged unions model alternatives so exhaustiveness checking works:
type Actions =
| { type: 'login'; username: string }
| { type: 'logout'; reason: 'session-timeout' | undefined }
| { type: 'update'; id: string; data: Record<string, unknown> }
A shared literal field (type or kind) lets TypeScript narrow each branch safely.
Summary
Consistency beats any single rule. Agree on conventions, document exceptions, and optimize for the next reader:
- Clear edge contracts
- Inferred internals
- Minimal suppressions
- Shared aliases and utilities
- Tagged unions where illegal combinations must disappear
Applied together, these habits keep TypeScript as a design aid rather than a tax: public APIs stay honest, internals stay DRY, and the remaining any or as casts become rare, reviewable exceptions instead of a default escape path.
Those references live in the official TypeScript handbook and JSDoc docs for readers who want the canonical wording.
Treat the list as a living team agreement rather than a fixed doctrine forever. Agreed.