| name | anti-slop-typescript |
|---|---|
| description | Simplify TypeScript code by removing defensive over-engineering, fake type safety, unnecessary helpers, redundant runtime checks, and abstraction noise. |
Apply these rules whenever modifying TypeScript or JavaScript code.
The goal is simple, readable, strongly typed code that trusts the type system and validates only at real boundaries.
Validate untrusted data once at the boundary. Trust typed application code everywhere else.
Do not repeatedly rediscover types that the application already knows.
Be suspicious of:
value: unknown
data: unknown
payload: unknown
Record<string, unknown>inside normal application code.
If the shape is known, use the actual type.
Bad:
const asDomainMapEventData = (
value: unknown,
): DomainMapEventData => {
if (typeof value !== "object" || value === null) {
return {};
}
return value as DomainMapEventData;
};Good:
const handleDomainMap = (
data: DomainMapEventData,
) => {
// ...
};Fix broad typing at the source instead of adding asX() helpers downstream.
Do not assume as is always bad.
Bad cleanup:
return (
typeof error === "object" &&
error !== null &&
"code" in error &&
error.code === DUPLICATE_KEY_ERROR_CODE
);when this is sufficient and clearer:
return (
error as { code?: number }
)?.code === DUPLICATE_KEY_ERROR_CODE;Use runtime validation when the value is genuinely untrusted.
Do not add five checks merely to avoid one intentional cast.
This is not real validation:
if (
typeof value === "object" &&
value !== null
) {
return value as Project;
}Either trust the value:
return value as Project;at a known integration point,
or validate it properly at the boundary with the project's existing schema library.
Do not write verbose checks that still end with an unsafe cast.
Bad:
private domainIdFrom(
document: Record<string, unknown> | undefined,
): string | undefined {
const domain = document?.domain;
return domain instanceof Types.ObjectId
? domain.toHexString()
: undefined;
}If the document shape is known, type it correctly:
interface DnsDocument {
domain: Types.ObjectId;
}Then:
return change.fullDocument?.domain.toHexString();Do not add:
domainIdFrom
projectIdFrom
userIdFrom
stringFrom
objectIdFrom
fieldFromto compensate for bad upstream typing.
Be suspicious of:
toString()
asString()
safeString()
stringifyValue()
normalizeValue()
toNumber()
asBoolean()
toRecord()
asRecord()Bad:
const stringify = (value: unknown): string => {
if (
typeof value === "string" ||
typeof value === "number"
) {
return `${value}`;
}
if (value == null) {
return "";
}
if (value instanceof Types.ObjectId) {
return value.toHexString();
}
return inspect(value);
};Ask:
What is this value actually supposed to be?
If it is a string, type it as a string.
If it is an ObjectId, type it as an ObjectId.
Do not write universal conversion utilities unless the application genuinely needs arbitrary-value serialization.
Bad:
const normalizeDomainName = (
value: unknown,
): string => {
if (typeof value === "string") {
return value.trim().toLowerCase();
}
if (typeof value === "number") {
return `${value}`.trim().toLowerCase();
}
return "";
};A domain name should not randomly be a number.
Prefer:
const normalizeDomainName = (
domain: string,
): string => {
return domain.trim().toLowerCase();
};Do not make function inputs broader than the actual domain model.
Search for:
return "";
return {};
return [];
return undefined;
return null;
value ?? "";
value || "";
value ?? {};
value || {};
value ?? [];
value || [];when they hide invalid states.
Bad:
if (!project) {
return {};
}If the project is required:
if (!project) {
throw new ProjectNotFoundError();
}If it is truly optional, model that in the type.
Do not silently degrade invalid application state into empty values.
Bad:
data?.project?.config?.domain?.namewhen the type guarantees those fields.
Prefer:
data.project.config.domain.nameUse optional chaining only when the value is genuinely optional.
Optional chaining should reflect the domain model, not uncertainty about the codebase.
Bad:
if (
typeof project.id !== "string"
) {
return "";
}when:
interface Project {
id: string;
}already guarantees it.
Trust the type.
If the type is wrong, fix the type or validate at the boundary.
Be suspicious of:
isRecord()
asRecord()
toRecord()
getString()
getNumber()
getBoolean()
getOptionalString()Bad:
const project = asRecord(data.project);
const id = getString(project, "id");Good:
const id = data.project.id;If the code relies heavily on:
Record<string, unknown>fix the architecture.
Bad:
type QueuePayload = {
event: string;
data: unknown;
};followed by:
asDomainMapEventData(data)
asDomainUnmapEventData(data)
asProjectSyncEventData(data)Prefer a discriminated union:
type QueueEvent =
| {
event: "domain.map";
data: DomainMapEventData;
}
| {
event: "domain.unmap";
data: DomainUnmapEventData;
}
| {
event: "project.ratelimit.sync";
data: ProjectRatelimitSyncEventData;
};Then:
switch (event.event) {
case "domain.map":
return handleDomainMap(event.data);
case "domain.unmap":
return handleDomainUnmap(event.data);
}Do not carry unknown into business logic.
Real boundaries include:
- HTTP requests
- webhooks
- queue messages
- external APIs
- environment variables
- raw database JSON
- user input
Validate there.
Then convert to a precise internal type.
Use the validation library already present in the codebase.
Do not introduce another schema library without a strong reason.
Be suspicious of helpers that:
- have one caller
- are 1–3 lines
- only access a field
- only call
.trim() - only call
.toLowerCase() - only cast a type
- only check null
- only return a default
- only forward arguments
Bad:
const getDomain = (
data: DomainData,
): string => data.domain;Prefer:
data.domainA helper should represent a real concept.
Bad:
async getProject(id: string) {
return this.projectService.getProject(id);
}when the wrapper adds no business logic, policy, mapping, or meaningful boundary.
Do not preserve layers solely because they exist.
Be suspicious of:
Project
ProjectData
ProjectDTO
ProjectInput
ProjectPayload
ProjectParams
ProjectResponse
ProjectModelwith nearly identical fields.
Separate types when the contracts are materially different.
Do not duplicate shapes just because each layer "should have its own type."
Audit functions like:
toDTO
fromDTO
toModel
fromModel
toEntity
fromEntity
toPayload
fromPayloadIf the source and target types are effectively identical, remove the unnecessary representation.
Do not maintain mapping code for no semantic reason.
Do not create interfaces merely because a class exists.
Bad:
interface ProjectService {
getProject(id: string): Promise<Project>;
}
class ProjectServiceImpl
implements ProjectService {
}when there is only one implementation and no meaningful consumer abstraction.
Prefer the concrete class.
Interfaces should solve a real problem.
Be suspicious of chains like:
Controller
→ Service
→ Manager
→ Processor
→ Handler
→ Repository
→ Storewhen most layers just forward data.
Collapse pass-through layers.
Prefer fewer meaningful layers.
Two similar lines do not automatically need a generic helper.
Bad:
const normalizeValue = <T>(
value: T,
normalizer: (value: T) => T,
): T => normalizer(value);Prefer obvious duplication over a generic abstraction that makes code harder to trace.
Be suspicious of:
function safeCast<T>()
function getValue<T>()
function normalize<T>()
function parseValue<T>()
function ensure<T>()when concrete types would be clearer.
Generics should solve a real reusable problem.
Do not use them merely to make helpers appear reusable.
Question aliases like:
type DomainString = string;
type EventValue = unknown;
type GenericData = Record<string, unknown>;Keep aliases when they add domain meaning or improve safety.
Do not create aliases that only rename primitives without value.
If states are mutually exclusive, model them properly.
Bad:
interface Deployment {
type: string;
web?: WebDeployment;
worker?: WorkerDeployment;
}Prefer:
type Deployment =
| {
type: "web";
web: WebDeployment;
}
| {
type: "worker";
worker: WorkerDeployment;
};Make impossible states impossible.
Search for:
String(value)
Boolean(value)
Number(value)
`${value}`
!!valuewhen the type already guarantees the primitive.
Bad:
const projectId = String(project.id);when project.id is already a string.
Prefer:
const projectId = project.id;Do not coerce values unnecessarily.
Bad:
String(undefined)
Number("")
Boolean("false")can produce misleading values.
Do not use coercion as validation.
Model valid values correctly.
Bad:
if (typeof value === "number") {
return String(value);
}for a domain name, email, UUID, hostname, event name, etc.
Do not make nonsensical values "work."
Reject them at the boundary or prevent them via types.
This is fine:
const domain =
input.domain.trim().toLowerCase();if the input comes from a user-controlled boundary.
The issue is not normalization.
The issue is accepting arbitrary types and silently coercing them.
Bad:
let shouldProcess = false;
if (event) {
if (event.enabled === true) {
shouldProcess = true;
}
}Prefer:
const shouldProcess =
event?.enabled === true;But do not create unreadable boolean one-liners.
Prefer readability.
Do not use nested ternaries.
Bad:
const value = active
? enabled
? "a"
: "b"
: "c";Use if, switch, or a simple variable assignment.
Ternaries should be short and obvious.
Bad:
if (project) {
if (project.enabled) {
if (project.status === "active") {
// 50 lines
}
}
}Prefer:
if (!project) {
return;
}
if (!project.enabled) {
return;
}
if (project.status !== "active") {
return;
}
// main logicKeep the happy path clear.
Bad:
const rawDomain = data.domain;
const normalizedDomain =
normalizeDomainName(rawDomain);
const domain = normalizedDomain;Prefer:
const domain =
data.domain.trim().toLowerCase();Intermediate variables should clarify meaning, not inflate code.
Delete comments that narrate syntax.
Bad:
// Check if project exists
if (!project) {Bad:
// Convert ObjectId to string
const id = objectId.toHexString();Keep comments for:
- business rules
- unusual constraints
- workarounds
- external quirks
- important invariants
Explain why, not what.
Bad:
try {
await operation();
} catch {
return undefined;
}Bad:
try {
await operation();
} catch (error) {
console.log(error);
}Determine whether the failure is genuinely recoverable.
Do not silently swallow errors.
Bad:
try {
return await service.run();
} catch (error) {
throw error;
}Remove the try/catch.
Only catch when you are:
- translating the error
- adding meaningful context
- cleaning resources
- intentionally recovering
Avoid:
Failed to process project:
Failed to load project:
Failed to fetch project:
Database error:
actual errorAdd context where it materially improves debugging.
Do not mechanically wrap every call.
Treat names like these as suspicious:
safeGet
safeString
safeNumber
safeParse
safeObject
safeArray
ensureObject
ensureString
ensureArrayThey often hide bad typing.
Fix the source type instead.
Bad:
new DeploymentBuilder()
.withProjectId(projectId)
.withRegion(region)
.withPort(port)
.build();when:
const deployment: Deployment = {
projectId,
region,
port,
};is clearer.
Likewise, do not add factories unless runtime selection or construction complexity actually exists.
Normal constructor injection is enough:
new ProjectService(
projectRepository,
logger,
);Do not add:
Container
Registry
Provider
Resolver
ServiceLocatorwithout a real need.
Audit files/folders named:
utils
helpers
common
shared
base
core
miscDelete trivial helpers.
Move domain-specific helpers to the domain that owns them.
Do not create another generic utility dumping ground.
If two call sites happen to look similar, do not automatically extract them.
Ask:
Is the shared concept real?
If not, leave the code explicit.
Small duplication can be cheaper than a bad abstraction.
Bad:
processValue(
value,
(value) => normalize(value),
);when:
normalize(value);is sufficient.
Do not turn normal calls into callback APIs without a reason.
Bad:
return new Promise(
async (resolve, reject) => {
try {
resolve(await run());
} catch (error) {
reject(error);
}
},
);Prefer:
return run();Do not wrap promises that already exist.
Bad:
async function getValue() {
return Promise.resolve(value);
}or:
async function getProject() {
return repository.getProject();
}when no await or async boundary is required.
Remove unnecessary async where it adds no value.
Be suspicious of:
return {
...value,
};or:
const copy = [...items];if there is no ownership/mutation reason.
Do not allocate "for safety" without a real requirement.
Bad:
constructor(
private readonly config:
Config = {} as Config,
) {}If the dependency is required, require it.
Do not silently construct invalid objects.
Do not write:
interface Project {
id?: string;
name?: string;
config?: Config;
}just because data could theoretically be incomplete.
If the application requires them:
interface Project {
id: string;
name: string;
config: Config;
}Optionality should reflect real business semantics.
Question:
string | null | undefinedwhen only one absence state is necessary.
Do not proliferate multiple empty states without a contract requiring them.
Bad:
if (value instanceof ObjectId) {
// ...
} else if (typeof value === "string") {
// ...
} else if (typeof value === "number") {
// ...
}when the domain says the value is an ObjectId.
Type it correctly.
If code reaches:
inspect(value)because the program does not know the value's type, inspect the root cause.
Do not make arbitrary values printable and call that correctness.
Bad:
logger.debug("starting project lookup");
logger.debug("project found");
logger.debug("normalizing domain");
logger.debug("mapping domain");Log meaningful operational events.
Do not narrate function execution.
Bad:
catch (error) {
logger.error(error);
throw error;
}when the caller also logs the same failure.
Prefer one responsible logging boundary.
Do not create classes just to group stateless helpers.
Bad:
class DomainUtils {
static normalize(domain: string) {
// ...
}
}Prefer:
const normalizeDomain = (
domain: string,
) => {
// ...
};Use classes when there is meaningful state or behavior.
Be suspicious when one class has dozens of tiny private methods like:
getDomainId()
extractProject()
normalizeName()
resolveState()
buildPayload()
prepareData()
formatValue()If they only make the reader jump around, inline them.
Private methods should clarify meaningful chunks of behavior.
Bad:
const isDomainEvent = (
value: unknown,
): value is DomainEvent => {
// 12 lines
};when it is used once inside a boundary that already has schema validation.
Do not duplicate schema validation with custom guards.
If the project already uses:
Zod
Yup
Joi
Valibot
TypeBox
Ajvuse it at untrusted boundaries.
Do not create custom validation utilities beside it.
If a simple trusted internal type is sufficient, do not add Zod just to validate internal objects.
Boundary validation and internal typing are different concerns.
Be suspicious of:
Partial<Project>
Partial<Config>
Partial<EventData>used deep in application logic.
If only some fields are needed, define the actual input type:
type ProjectUpdate = {
name?: string;
region?: string;
};Do not weaken large domain types just for convenience.
Bad:
Record<string, Handler>when valid keys are known.
Prefer:
Record<EventName, Handler>or a typed object.
Make invalid keys impossible where useful.
Search for:
as anyFix the type mismatch instead where practical.
Do not use as any merely to silence the compiler.
At the same time, do not replace a small intentional cast with huge runtime narrowing.
Prefer an honest local cast over fake defensive machinery when the external contract guarantees the shape.
Use judgment.
For Mongoose/Mongo code, avoid:
Record<string, unknown>
unknown
anyfor known document shapes.
Define the document type.
Bad:
const domain =
document?.domain;
return domain instanceof Types.ObjectId
? domain.toHexString()
: undefined;when the schema guarantees:
domain: Types.ObjectIdPrefer:
return document?.domain.toHexString();If the schema and TypeScript types guarantee a field's type, do not repeatedly check:
instanceof Types.ObjectId
typeof value === "string"inside normal application code.
Fix schema/type alignment if necessary.
HTTP handlers should parse/validate external input.
Services should not receive:
unknown
Record<string, unknown>unless they are themselves the parsing boundary.
Prefer:
service.createProject(input);where input is already typed and validated.
Bad:
syncProject(
payload: ProjectSyncPayload,
)when the function only needs:
projectId
rateLimitPrefer:
syncProject(
projectId: string,
rateLimit: number,
)Pass only what the function actually needs.
Bad:
type ExistsResult = {
exists: boolean;
};when:
Promise<boolean>is sufficient.
Use result objects when multiple related values need to travel together.
Do not turn every string into an enum.
Use enums or literal unions when there is a real closed set of values.
Prefer:
type Status =
| "pending"
| "running"
| "failed";when that fits the codebase.
Do not create abstractions around arbitrary strings for no reason.
Do not turn:
switch (event.type) {
case "insert":
case "update":
case "delete":
}into:
HandlerRegistry
EventProcessor
StrategyFactory
EventHandlerInterfaceunless dynamic registration is actually needed.
A switch is fine.
Three branches do not automatically need three classes.
Prefer direct control flow when easier to understand.
Avoid custom wrappers around:
JSON.parse
Array.isArray
Object.keys
Object.entries
String.prototype.trim
String.prototype.toLowerCase
Map
Setunless the wrapper adds meaningful domain behavior.
If fixing a type makes these obsolete:
asX
isX
normalizeX
safeX
convertX
extractXdelete them.
Do not leave compatibility helpers behind unless they still have real callers and purpose.
Treat these patterns as suspicious:
const asX = (
value: unknown,
): X => ...const safeX = ...const normalizeX = (
value: unknown,
) => ...if (
typeof value === "object" &&
value !== null &&
"foo" in value
)value ?? ""value ?? {}value ?? []return inspect(value)return String(value)Record<string, unknown>They are not automatically wrong, but they deserve scrutiny.
A refactor is suspicious if it turns:
return (
error as { code?: number }
)?.code === DUPLICATE_KEY_ERROR_CODE;into:
return (
typeof error === "object" &&
error !== null &&
"code" in error &&
typeof error.code === "number" &&
error.code === DUPLICATE_KEY_ERROR_CODE
);without adding meaningful correctness.
Likewise, replacing:
document.domain.toHexString()with:
this.extractAndNormalizeDomainId(
document,
)is not an improvement.
Whenever you see:
asSomething
safeSomething
normalizeSomething
extractSomething
resolveSomething
convertSomethingtrace the value upstream.
Ask:
- Why is the value broad?
- Where does it enter the application?
- Is that the correct place to validate it?
- Can the downstream type become precise?
- Can the helper then disappear?
Prefer fixing the earliest sensible point in the data flow.
Do not remove checks that protect against genuine uncertainty.
Keep handling for:
- external API failures
- database not-found cases
- nullable database fields
- malformed HTTP input
- malformed queue messages
- optional business fields
- Redis misses
- JSON parse failures
- security checks
- authorization checks
- external library behavior that is actually broad
The distinction is:
Defend against external uncertainty, not against your own typed application code.
Do not:
- change business logic
- change public API behavior
- change event names
- change persistence formats
- remove security checks
- remove legitimate nullability
- introduce new dependencies without strong justification
This is a complexity reduction exercise.
Prefer:
const handleDomainMap = async (
data: DomainMapEventData,
) => {
const domain =
data.domain.trim().toLowerCase();
await domainService.map({
projectId: data.projectId,
domain,
});
};over:
const handleDomainMap = async (
rawData: unknown,
) => {
const data =
asDomainMapEventData(rawData);
const projectId =
asOptionalString(data.projectId);
const domain =
normalizeDomainName(
getSafeValue(data.domain),
);
if (!projectId || !domain) {
return;
}
await domainService.map({
projectId,
domain,
});
};Before editing code:
- Identify the trusted and untrusted boundaries.
- Understand the actual domain types.
- Check whether broad types are accidental.
- Avoid introducing helpers before understanding upstream typing.
- Prefer modifying the root type/data flow over patching call sites.
Before finishing any TypeScript change, inspect the diff.
For every added helper, type, branch, fallback, cast, or runtime check, ask:
- Does this handle a state that can genuinely occur?
- Is this compensating for bad typing upstream?
- Could the type system express this instead?
- Did I add more code than the problem requires?
- Did I introduce a helper with only one trivial caller?
- Did I replace a direct operation with an abstraction?
- Did I silently convert invalid input into an empty value?
- Did I make the code harder to trace?
- Did this change increase total complexity?
- Can any newly added code be deleted while preserving correctness?
If yes, simplify before completing the task.
Do not transform:
simple typed value
→ direct operationinto:
unknown
→ runtime narrowing
→ Record<string, unknown>
→ extractor
→ normalizer
→ fallback
→ helper
→ actual operationThe desired flow is:
untrusted input
→ validate once
→ precise type
→ direct business logicThe overriding principle is:
Make external input safe at the edge. Keep internal TypeScript simple, direct, and strongly typed.