· 7 min read
10 Mind-Blowing TypeScript Hacks You Didn't Know Existed
Discover 10 lesser-known TypeScript features and tricks-from the 'satisfies' operator to deep path types-that can make your code safer, shorter, and more expressive.
Intro
TypeScript keeps getting more powerful, and with each release there are little gems that can dramatically improve the ergonomics and safety of your code. Below are 10 practical - and sometimes surprising - TypeScript hacks that many developers haven’t discovered yet. Each one includes a compact example, why it matters, and when to reach for it.
Hack 1 - Use the satisfies
operator to preserve literal types while checking shapes
Why: as const
widens to readonly literal shapes, but sometimes you want TypeScript to check that a value matches a type while still preserving its narrow literal types. satisfies
does exactly that.
Example:
// Without satisfies - type of `routes` is widened (string) if you don't use const
const routes = ['/', '/about'] as const; // preserves literal but is readonly
// Using `satisfies` (TS 4.9+)
const config = {
base: '/',
routes: ['/', '/about'],
} satisfies { base: string; routes: readonly string[] };
// `config.routes[0]` still has type "/" (literal) because `satisfies` doesn't widen the RHS
When to use: factory returns, complex config objects, or action creators where you want both a compile-time shape check and preserved literal types.
Reference: https://devblogs.microsoft.com/typescript/announcing-typescript-4-9/
Hack 2 - Key remapping in mapped types: rename & filter properties elegantly
Why: You can transform keys in mapped types (including renaming or filtering) using the as
clause in a mapped type.
Example - strip an on
prefix and lower-case the event name:
type Handlers = {
onClick: (e: Event) => void;
onMouseEnter: (e: Event) => void;
onSubmit: () => void;
};
type NormalizedHandlers = {
[K in keyof Handlers as K extends `on${infer Rest}`
? Uncapitalize<Rest>
: K]: Handlers[K];
};
// Resulting type has keys: "click", "mouseEnter", "submit"
When to use: building adapter types, converting API shapes, creating public-facing typed APIs from internal objects.
Reference: https://www.typescriptlang.org/docs/handbook/2/mapped-types.html
Hack 3 - Control distributive conditional types (and convert unions -> intersections)
Why: Conditional types distribute over unions by default, which is often desirable - but sometimes you want to treat the union as a single unit. Wrapping the checked type in a tuple stops distribution.
Example - prevent distribution:
type T1<T> = T extends string ? 'yes' : 'no';
// T1<'a' | 1> => 'yes' | 'no' (distributes)
type T2<T> = [T] extends [string] ? 'yes' : 'no';
// T2<'a' | 1> => 'no' (does not distribute; the union as a whole isn't assignable to string)
Union -> intersection trick (advanced):
type UnionToIntersection<U> = (
U extends any ? (arg: U) => void : never
) extends (arg: infer I) => void
? I
: never;
// union A | B becomes A & B
When to use: complex conditional types, type-level programming, building utility types that must behave non-distributively.
Reference: https://www.typescriptlang.org/docs/handbook/2/conditional-types.html
Hack 4 - Parse and extract parts of strings with template literal types + infer
Why: Template literal types + infer
let you pattern-match and extract parts of string types at the type level. Very powerful for parsing route params, event names, or key patterns.
Example - extract route params from path strings:
type ExtractParam<S> = S extends `:${infer Param}/${infer Rest}`
? Param | ExtractParam<`/${Rest}`>
: S extends `:${infer Param}`
? Param
: never;
type ParamsOf<Path extends string> =
| ExtractParam<Path>
| ExtractParam<`/${Path}`>;
type P = ParamsOf<'/users/:id/posts/:postId'>; // 'id' | 'postId'
When to use: type-safe route helpers, parsing DSL-like strings, deriving typed payloads from string patterns.
Reference: https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html
Hack 5 - Variadic tuple types: write type-safe utilities for variable arguments
Why: Variadic tuple types let you express functions that accept or return tuples of arbitrary length while preserving precise types, enabling typed zip
, prepend
, tail
operations, etc.
Example - simple typed zip:
function zip<T extends readonly unknown[], U extends readonly unknown[]>(
a: [...T],
b: [...U]
): { [K in keyof T & keyof U]: [T[K], U[K]] } {
const len = Math.min(a.length, b.length);
const out: unknown[] = [];
for (let i = 0; i < len; i++) out.push([a[i], b[i]]);
return out as any;
}
const a = [1, 2] as const;
const b = ['x', 'y'] as const;
const z = zip(a, b); // type: [[1, 'x'], [2, 'y']]
When to use: typed tuple transforms, small DSLs, implementing typed currying/compose utilities.
Reference: https://www.typescriptlang.org/docs/handbook/2/tuples.html#variadic-tuple-types
Hack 6 - Create nominal/branded types with unique symbol
Why: TypeScript is structurally typed. If you want to prevent accidental mixing of two distinct strings (e.g., UserId vs OrderId), create a branded type to get nominal-like behavior.
Example:
const userIdBrand: unique symbol = Symbol();
type UserId = string & { readonly [userIdBrand]: unique symbol };
function makeUserId(s: string): UserId {
return s as UserId;
}
const id = makeUserId('abc');
function takeUserId(u: UserId) {}
takeUserId(id); // OK
takeUserId('abc'); // Error: string is not assignable to UserId
When to use: external IDs, handles, or any scalar that must be distinct at the type level.
Hack 7 - Bind methods safely using ThisParameterType
and OmitThisParameter
Why: Methods often declare a this
type. Use utility types to create bound or transformed function types where this
is removed or changed, enabling safe .bind
wrappers.
Example - convert method type to a simple function type (no this):
type FnNoThis<T extends (...args: any) => any> = OmitThisParameter<T>;
class Greeter {
constructor(private name: string) {}
greet(this: Greeter, who: string) {
return `Hello ${who}, I'm ${this.name}`;
}
}
const g = new Greeter('TS');
const fn: FnNoThis<typeof g.greet> = g.greet.bind(g);
// `fn` is now (who: string) => string
When to use: creating safe wrappers around methods, implementing bind helpers or APIs that take callbacks without a this
.
Reference: https://www.typescriptlang.org/docs/handbook/utility-types.html
Hack 8 - Exhaustiveness checks with never
and an assertNever
helper
Why: Want the compiler to ensure you handled every branch of a discriminated union? Use a never
-based helper so a missing case becomes a compile error.
Example:
type Shape = { kind: 'circle'; r: number } | { kind: 'square'; s: number };
function assertNever(x: never): never {
throw new Error('Unexpected: ' + JSON.stringify(x));
}
function area(s: Shape) {
switch (s.kind) {
case 'circle':
return Math.PI * s.r ** 2;
case 'square':
return s.s ** 2;
default:
return assertNever(s); // compile-time error if a new Shape is added and not handled
}
}
When to use: switching on discriminants, exhaustive unions, and ensuring future-proof checks.
Hack 9 - Runtime assertion functions (the asserts
return type)
Why: Assertion functions using asserts foo is Bar
both throw at runtime (if you want) and inform the compiler about narrowed types afterwards. This is stronger than plain boolean guards for critical validations.
Example:
function assertIsString(x: unknown): asserts x is string {
if (typeof x !== 'string') throw new TypeError('Expected string');
}
function greet(x: unknown) {
assertIsString(x);
// `x` is now *narrowed to string* in the rest of this scope
return 'hello ' + x.toUpperCase();
}
When to use: parsing input, validating third-party data, or when you need a runtime guarantee plus compile-time narrowing.
Reference: https://www.typescriptlang.org/docs/handbook/2/narrowing.html#assertion-functions
Hack 10 - Build type-safe deep property accessors (Paths & DeepGet)
Why: Want to allow only valid dot-separated paths into an object (e.g., get(obj, ‘a.b.c’))? You can generate a union of valid paths with recursive mapped/template types and then index into the object type safely.
Example - generate dot-paths and a typed getter:
type Primitive = string | number | boolean | null | undefined | symbol | bigint;
type Paths<T> = T extends Primitive
? ''
: {
[K in keyof T & (string | number)]:
| `${K}`
| (Paths<T[K]> extends '' ? never : `${K}.${Paths<T[K]>}`);
}[keyof T & (string | number)];
type DeepGet<T, P extends string> = P extends `${infer K}.${infer Rest}`
? K extends keyof T
? DeepGet<T[K], Rest>
: never
: P extends keyof T
? T[P]
: never;
// Usage
type Obj = { a: { b: { c: number } }; x: string };
type P = Paths<Obj>; // 'a' | 'a.b' | 'a.b.c' | 'x'
function get<T, P extends Paths<T>>(obj: T, path: P): DeepGet<T, P> {
return (path.split('.') as any).reduce((o, k) => o && o[k], obj);
}
const o: Obj = { a: { b: { c: 5 } }, x: 'hi' };
const v = get(o, 'a.b.c'); // typed as number
When to use: typed configuration readers, deep form value accessors, or libraries where consumers pass string paths but you want type safety.
When to avoid overusing advanced types
Some of these techniques are amazingly powerful, but they can increase compile times and make error messages harder to read. Use them where they improve safety or developer experience (APIs, libraries, and critical data-shaping parts of your code), and prefer simpler typings for internal, rapidly-changing code.
Further reading
- TypeScript Handbook - Mapped Types: https://www.typescriptlang.org/docs/handbook/2/mapped-types.html
- Template Literal Types: https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html
- Conditional Types: https://www.typescriptlang.org/docs/handbook/2/conditional-types.html
- Variadic Tuple Types: https://www.typescriptlang.org/docs/handbook/2/tuples.html#variadic-tuple-types
- Announcing TypeScript 4.9 (satisfies): https://devblogs.microsoft.com/typescript/announcing-typescript-4-9/