The Five Custom TS Utility Types I Add to Every Project
TypeScript ships great utility types but the five I miss in every fresh project are these: NonEmptyArray, Branded, Awaited inverse (Promisify), DeepReadonly, and ExactKeys. Each is one or two lines that has saved me from a bug.
By @tylerperry
March 21, 2026
·
Updated May 20, 2026
834 views
14
4.3 (14)
// NonEmptyArray<T>: a tuple-like type that guarantees at least one element
// at the type level. Lets you call arr[0] without `| undefined` in noUncheckedIndexedAccess.
type NonEmptyArray<T> = [T, ...T[]];
function firstWord(words: NonEmptyArray<string>): string {
// No `?? throw` needed; the type guarantees words[0] is a string.
return words[0];
}
// Branded<T, B>: nominal typing for primitives. Stops you from passing a
// raw string where a UserId is expected.
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, 'UserId'>;
type OrgId = Brand<string, 'OrgId'>;
function asUserId(s: string): UserId { return s as UserId; }
function asOrgId(s: string): OrgId { return s as OrgId; }
function lookupUser(id: UserId) { return 'user:' + id; }
const u = asUserId('u_42');
const o = asOrgId('o_7');
console.log(lookupUser(u));
// lookupUser(o); // would be a type error: OrgId is not UserId
// lookupUser('u_42'); // would be a type error: raw string is not UserId
// Demonstrate NonEmptyArray.
console.log(firstWord(['hello', 'world']));
// firstWord([]); // would be a type error at compile timeNonEmptyArray is the tiny win that prevents the most common TypeScript bug I see in code review: indexing an array and getting T | undefined. By declaring at the type level that the array has at least one element, downstream code can use arr[0] without a guard. Branded<T, B> solves the opposite-shape problem: the compiler treats string as a universal currency for ids, but in practice mixing up a UserId and an OrgId is exactly the kind of bug that ships to production. The phantom __brand field is erased at runtime, so the cost is zero, and the compiler treats UserId and OrgId as incompatible.
// Readonly<T> only freezes the top level. DeepReadonly walks objects, arrays,
// tuples, Maps, and Sets recursively. I use it on every config value loaded
// from disk so a stray mutation in a deeply-nested option becomes a compile error.
type DeepReadonly<T> =
T extends (infer U)[]
? ReadonlyArray<DeepReadonly<U>>
: T extends Map<infer K, infer V>
? ReadonlyMap<DeepReadonly<K>, DeepReadonly<V>>
: T extends Set<infer V>
? ReadonlySet<DeepReadonly<V>>
: T extends object
? { readonly [P in keyof T]: DeepReadonly<T[P]> }
: T;
type RawConfig = {
api: { baseUrl: string; retries: number };
flags: { darkMode: boolean; betaCohorts: string[] };
};
declare const cfg: DeepReadonly<RawConfig>;
// cfg.api.retries = 5; // compile error
// cfg.flags.betaCohorts.push('x'); // compile error
// Runtime demo: build a nested object and confirm the runtime view.
const config: RawConfig = {
api: { baseUrl: 'https://api.example.com', retries: 3 },
flags: { darkMode: true, betaCohorts: ['internal'] },
};
function freezeDeep(obj: unknown): unknown {
if (obj && typeof obj === 'object' && !Object.isFrozen(obj)) {
for (const v of Object.values(obj as Record<string, unknown>)) freezeDeep(v);
Object.freeze(obj);
}
return obj;
}
const frozen = freezeDeep(config) as DeepReadonly<RawConfig>;
console.log('baseUrl:', frozen.api.baseUrl);
console.log('retries:', frozen.api.retries);
console.log('cohorts:', frozen.flags.betaCohorts);
console.log('top-level frozen:', Object.isFrozen(frozen));
console.log('nested frozen :', Object.isFrozen(frozen.api));DeepReadonly is the type I want every time I load a config or a feature-flag map. The conditional type checks for the four collection shapes I care about (array, Map, Set, plain object) before falling through to the leaf case. Pairing it with a runtime freezeDeep walker gives both compile-time and runtime safety; without the runtime side a type assertion can bypass the check, and without the type side an editor will not warn you about a forbidden mutation. I have caught at least three race conditions where one module mutated config.flags.betaCohorts.push('foo') and a later module saw the side effect; the type alone would have flagged the push at compile time.
// Awaited<T> exists since TS 4.5 and unwraps a promise. The inverse (turn a
// function returning T into a function returning Promise<T>) is what I need
// for migrating sync APIs to async.
type Promisify<F> = F extends (...args: infer A) => infer R
? (...args: A) => Promise<Awaited<R>>
: never;
type RetryStrategy = (attempt: number) => number;
type AsyncRetryStrategy = Promisify<RetryStrategy>;
// ^? (attempt: number) => Promise<number>
// Practical use: typed event emitter where the listener can be sync or async.
type Listener<T> = (event: T) => void | Promise<void>;
function makeEmitter<E>() {
const listeners: Listener<E>[] = [];
return {
on(fn: Listener<E>) { listeners.push(fn); },
async emit(event: E) {
for (const fn of listeners) {
const r = fn(event);
if (r && typeof (r as Promise<void>).then === 'function') {
await r;
}
}
},
};
}
const emitter = makeEmitter<{ kind: 'user-signed-up'; id: string }>();
emitter.on((e) => console.log('sync :', e.kind, e.id));
emitter.on(async (e) => {
await Promise.resolve();
console.log('async:', e.kind, e.id);
});
(async () => {
await emitter.emit({ kind: 'user-signed-up', id: 'u_42' });
console.log('all listeners ran');
})();Promisify is the type I write when migrating a sync API to async without breaking call sites. The conditional type infers the function's argument tuple A and return type R, then rebuilds the signature with Promise<Awaited<R>> so a function that already returned a Promise does not get wrapped twice. The Awaited is what handles the double-promise case (Promise<Promise<T>> collapses to Promise<T>). Pairing it with a typed event emitter where Listener<T> accepts either sync or async handlers is the production pattern that uses these types together. The r && r.then check at runtime is needed because TypeScript cannot statically tell which branch a listener took.
// TypeScript object types are 'open': passing { a: 1, b: 2 } where { a: number }
// is expected works fine. ExactKeys flips that to 'closed': any unknown key is
// a type error. Useful for option bags where typos are bug factories.
type ExactKeys<T, U extends T> = U & Record<Exclude<keyof U, keyof T>, never>;
function makeRequest<U extends RequestOptions>(opts: ExactKeys<RequestOptions, U>) {
return { url: opts.url, method: opts.method ?? 'GET', timeout: opts.timeout ?? 5000 };
}
type RequestOptions = {
url: string;
method?: 'GET' | 'POST';
timeout?: number;
};
// Allowed: every key is in RequestOptions.
console.log(makeRequest({ url: '/api/users', method: 'GET' }));
console.log(makeRequest({ url: '/api/users', timeout: 3000 }));
// Disallowed at compile time:
// makeRequest({ url: '/x', tiemout: 3000 }); // typo on 'timeout' is a type error
// makeRequest({ url: '/x', headers: {} }); // 'headers' is not a known option
// Show the runtime behavior is identical; the type does the heavy lifting.
console.log('default method:', makeRequest({ url: '/x' }).method);
console.log('default timeout:', makeRequest({ url: '/x' }).timeout);ExactKeys is the type I add to every public API surface where call-site typos would silently be ignored. The trick is the intersection: Record<Exclude<keyof U, keyof T>, never> says "any key in U that is not in T must have type never". Since never is uninhabited, providing a real value triggers a type error. The reason this matters is that TypeScript's normal subtyping happily accepts extra fields, so a typo like tiemout: 3000 simply slips through to runtime where it is silently discarded. Using ExactKeys at the boundary catches those at the call site without affecting the implementation, which still consumes the standard RequestOptions shape.
