· 8 min read
TypeScript Hacks that Can Save Your Project from Going Off the Rails
Concrete TypeScript techniques that helped teams recover failing or stalled projects - with code examples, when to use them, and the lessons learned from real-world rescues.
Introduction
TypeScript is a force multiplier for large JavaScript codebases - but only when used wisely. In real-world projects you’ll often hit deadlines, brittle JS-to-TS migrations, mismatched external types, and performance problems. When that happens, pragmatic TypeScript “hacks” (workarounds and targeted techniques) can buy you time, reduce risk, and give your team breathing room to do the right refactor later.
Below are concrete, battle-tested techniques I’ve seen rescue projects. Each section shows: the problem, a compact hack (with code), why it works, and the lessons learned.
- Keep configs safe without losing literal types: use
as const
+satisfies
Problem: You need compile-time validation of a large configuration object but want to preserve literal types (for union narrowing or template types). Naively casting to a type loses literal inference.
Hack: Use as const
to preserve literal inference and satisfies
to check the shape without widening types.
Example:
// preferredConfig.ts
const config = {
env: 'production',
port: 8080,
features: {
enableFoo: true,
},
} as const satisfies {
env: 'production' | 'staging' | 'development';
port: number;
features: { enableFoo: boolean };
};
// 'config.env' is typed as '"production"' (literal) while the object is checked for compatibility
Why it helps: satisfies
ensures the object matches the required shape but doesn’t widen literal types, which keeps downstream discriminated-union behavior intact.
References: TypeScript release notes on the satisfies
operator: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-9.html#the-satisfies-operator
Lesson: Use satisfies
when you need shape checking and literal preservation. It’s safer than as T
because as T
can silence shape mismatches.
- Avoid ID mixing with branded (nominal) types
Problem: You have multiple numeric/string IDs (userId, orderId) and they accidentally get mixed, causing subtle runtime bugs.
Hack: Introduce lightweight branded types that are still erased at runtime, but prevent accidental interchange at compile time.
Example:
type Brand<K, T> = K & { __brand: T };
type UserId = Brand<number, 'UserId'>;
type OrderId = Brand<number, 'OrderId'>;
function toUserId(n: number): UserId {
return n as UserId;
}
function toOrderId(n: number): OrderId {
return n as OrderId;
}
function getUserById(id: UserId) {
/* ... */
}
const u = toUserId(123);
const o = toOrderId(123);
getUserById(u); // OK
getUserById(o); // compile error: OrderId not assignable to UserId
Why it helps: Branded types give you nominal-like guarantees while remaining zero-cost at runtime.
References: Nominal/Branded types explained in community resources such as Basarat’s TypeScript Deep Dive: https://basarat.gitbook.io/typescript/type-system/nominal
Lesson: Use branding for IDs or other semantically distinct primitives.
- Incremental adaptions: module augmentation for libraries (e.g., Express’s Request.user)
Problem: A third-party library is missing a property your middleware attaches (e.g., Express’s Request.user). You need a minimal, safe, local fix instead of forking type packages.
Hack: Use declaration merging / module augmentation to declare the property on the library’s type locally.
Example:
// src/types/express.d.ts
import express from 'express';
declare global {
namespace Express {
interface Request {
user?: { id: string; roles: string[] };
}
}
}
// or module augmentation style
// declare module 'express-serve-static-core' {
// interface Request { user?: { id: string } }
// }
Why it helps: You don’t need to modify third-party packages. The augmentation applies across your project and restores type-safety where middlewares expect .user
.
References: Declaration merging in the TypeScript handbook: https://www.typescriptlang.org/docs/handbook/declaration-merging.html
Lesson: Prefer local augmentation for small, controlled extensions; add comments and link to upstream issues so future maintainers can remove the local hack when the library improves.
- Validate untrusted JSON with assertion functions to get type narrowing
Problem: Fetching JSON from an external API yields any
/unknown
. Blind casting to application types hides bugs and crashes at runtime.
Hack: Write small runtime assertion functions (type predicates) that both validate and narrow types for the compiler using asserts
.
Example:
function assertIsUser(x: unknown): asserts x is { id: string; name: string } {
if (
typeof x !== 'object' ||
x === null ||
typeof (x as any).id !== 'string' ||
typeof (x as any).name !== 'string'
) {
throw new Error('Invalid user');
}
}
async function getUser() {
const raw = await fetch('/api/user').then(r => r.json());
assertIsUser(raw);
// now 'raw' is narrowed to '{ id: string; name: string }'
return raw;
}
Why it helps: This combines runtime safety and compile-time guarantees - preferable to unchecked casts.
References: Assertion functions in the handbook: https://www.typescriptlang.org/docs/handbook/2/functions.html#assertion-functions
Lesson: Use small, well-tested assertion helpers at API boundaries. Keep them simple and comprehensively tested.
- Buy time: use
// @ts-expect-error
(or// @ts-ignore
) with a TODO and an issue
Problem: A weird typing error prevents a hotfix deploy and the fastest path is to temporarily silence the compiler.
Hack: Add // @ts-expect-error
above the offending line, include a short comment, and create a tracking ticket to remove it.
Example:
// TODO: remove this once upstream lib fixes overloads -> ISSUE-1234
// @ts-expect-error -- TS2345: overload mismatch in lib-foo
doSomething(legacyData);
Why it helps: @ts-expect-error
is explicit - it will error if the compiler stops producing that error (so it forces cleanup) unlike @ts-ignore
which silently hides future issues.
Lesson: Make these comments visible in PRs, attach a ticket, and set a deadline. Don’t let @ts-ignore
comments accumulate.
- Unblock CI:
skipLibCheck
for problematic third-party types
Problem: A dependencies’ type definitions cause the type checker to fail for reasons outside your control (especially in monorepos with multiple TypeScript versions).
Hack: Turn on skipLibCheck: true
in tsconfig to skip type-checking of declaration files (.d.ts) until upstream fixes arrive.
Why it helps: It unblocks development and CI while keeping your own sources strictly typed.
Trade-offs: You lose coverage over third-party type issues and might miss incompatibilities introduced by upstream changes.
Reference: tsconfig option: https://www.typescriptlang.org/tsconfig#skipLibCheck
Lesson: Use this as a temporary measure, and log a follow-up to either upgrade dependencies or file issues with the library author.
- Tackle large codebase reorgs with compiler path aliases
Problem: Deep relative imports (../../../../) make moving files error-prone and fragile.
Hack: Configure paths
+ baseUrl
in your tsconfig and update your build tooling to understand the aliases.
Example:
{
"compilerOptions": {
"baseUrl": "./src",
"paths": {
"@app/*": ["app/*"],
"@lib/*": ["lib/*"]
}
}
}
Why it helps: You can move modules and refactor imports quickly with predictable aliasing.
Caveat: Ensure your test runner, bundler, and editor are configured to resolve these aliases.
Lesson: Standardize on aliases early and include configuration snippets for the tooling you use.
- Replace
any
withunknown
and small type guards to limit spread of unsafety
Problem: any
propagates unsafely; a large legacy codebase uses any
pervasively.
Hack: Convert any
to unknown
at module boundaries and write minimal type guards where needed. This forces callers to handle unknowns explicitly.
Example:
// inbound.ts
export type RawPayload = unknown;
// consumer.ts
import { RawPayload } from './inbound';
function isStringArray(x: unknown): x is string[] {
return Array.isArray(x) && x.every(i => typeof i === 'string');
}
function consume(payload: RawPayload) {
if (!isStringArray(payload)) throw new Error('Bad payload');
// now payload: string[]
}
Why it helps: unknown
prevents accidental usage without runtime checks.
Reference: unknown type docs: https://www.typescriptlang.org/docs/handbook/basic-types.html#unknown
Lesson: Prefer unknown
as a stopgap when migrating and make small, testable guards to keep runtime checks minimal.
- Derive DTOs and avoid duplication with mapped/conditional types
Problem: You must keep DTOs, database types, and view models in sync - duplication causes drift.
Hack: Derive types via mapped/conditional types (e.g., Partial / DeepPartial / Pick / Omit) to express relationships once and reuse them.
Example (shallow DTO):
type User = { id: string; name: string; email: string; passwordHash: string };
type PublicUser = Omit<User, 'passwordHash'>;
Example (DeepPartial):
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
Why it helps: One source of truth reduces risk. When you must change a base type, derived types change automatically.
Lesson: Keep derived types simple and well-documented. Prefer built-in utilities (Pick/Omit/Partial) before rolling complex recursive types.
- Scale compile performance and repo boundaries with Project References
Problem: Large repos become slow to type-check and tests depend on a full rebuild.
Hack: Use TypeScript Project References to split the repo into smaller builds with explicit dependencies.
Why it helps: Incremental builds are faster and the structure forces encapsulation.
References: Project references: https://www.typescriptlang.org/docs/handbook/project-references.html
Lesson: Project references require additional build orchestration but pay off for large monorepos. Start with a few logical packages and measure.
Checklist before applying a hack
- Is this a short-term unblock or a permanent design choice?
- Can this change be easily documented and tracked with a ticket and tests?
- Does it increase or decrease runtime safety?
- Will teammates and CI understand the change (e.g., do tooling configs need updates)?
Practical process advice
- Annotate every intentional suppression (e.g.,
@ts-expect-error
) with a TODO and issue number. - Add tests around assertion functions and guards; runtime checks should be covered.
- Prefer typed wrappers (brands, DTO conversions) over scattering casts.
- Remove hacks in a follow-up cleanup PR. Use the hack to unblock shipping, not to postpone engineering.
Further reading and references
- TypeScript Handbook - Assertion functions: https://www.typescriptlang.org/docs/handbook/2/functions.html#assertion-functions
- Satisfies operator (TS 4.9) release notes: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-9.html#the-satisfies-operator
- Declaration merging: https://www.typescriptlang.org/docs/handbook/declaration-merging.html
- skipLibCheck TS config: https://www.typescriptlang.org/tsconfig#skipLibCheck
- Project references: https://www.typescriptlang.org/docs/handbook/project-references.html
- Basarat’s notes on nominal types: https://basarat.gitbook.io/typescript/type-system/nominal
Conclusion
In pressured, real-world projects, TypeScript’s flexibility gives you many pragmatic levers. The goal isn’t to apply hacks forever - it’s to unblock delivery while preserving compile-time guarantees as much as possible. Use satisfies
and as const
to keep literal behavior, brands to avoid semantic mistakes, assertion functions to guarantee runtime safety, and small, documented suppressions to keep shipping. Combine these with a short, prioritized cleanup plan and the next release becomes a refactor instead of a firefight.