TypeScript can be sexy to work with
Because `any` was never the answer to your hundreds of TypeScript questions
This post isn't your "top 10 TypeScript tricks" listicle. It's a deep dive into the type system complexities I've battled in production codebases across frameworks.
On this Page
This is an infinite rabbit hole
Most engineers hit TypeScript and stop at interface definitions and maybe a sprinkle of generics. While useful, these are just the tip of the iceberg. Let's look further - into the places where TypeScript's type system becomes a tiny bit of magic.
Ever wonder why this works?
1type Equals<X, Y> =2 (<T>() => T extends X ? 1 : 2) extends3 (<T>() => T extends Y ? 1 : 2) ? true : false;4
5type IsTheSame = Equals<{ a: string }, { readonly a: string }>; // false
For starters, this is a type-level function using conditional types and function inference to compare structural equality. Using functions like this at the type level without ever executing them at runtime can really help you out.
This works because TypeScript determines equality by checking two generic function types for mutual assignability.
This means that by embedding a conditional type in the return position (T extends X ? 1 : 2
vs. T extends Y ? 1 : 2
),
the compiler ensures the functions are interchangeable only if, for every T
, both conditionals yield the same result.
So, Equals<X, Y>
is true
exactly when X
and Y
are structurally identical. 😎
Pickin' with Literals
Let's build something genuinely useful: a type-safe version of _.pick
that preserves literal types:
1type PickWithLiterals<T, K extends keyof T> = {2 [P in K]: T[P]3} & (T extends { [key: string]: infer V } ? { [key: string]: never } : {});4
5interface User {6 id: string;7 name: string;8 role: 'admin' | 'user';9 settings: { theme: string };10 lastSeen: Date;11}12
13// Standard Pick widens 'role' - not always what we want...14type UserBasicInfo = Pick<User, 'name' | 'role'>;15// My version preserves the literal 'admin' | 'user' - 🎉16type UserInfoPreserved = PickWithLiterals<User, 'name' | 'role'>;
The real magic here is the intersection with a conditional mapped type that ensures any string index type is overridden with never
, preserving our exact property types.
Trust me. Moving beyond generic utility types to creating your own domain-specific type utilities like this will massively reduce duplication and errors in your codebase(s).
Recursion makes the world go 'round
Ever tried to type deep objects with arbitrary nesting? Let's create a fully type-safe JSON parser that preserves literal types:
1type JSONPrimitive = string | number | boolean | null;2type JSONArray = JSONValue[];3type JSONObject = { [key: string]: JSONValue };4type JSONValue = JSONPrimitive | JSONObject | JSONArray;5
6function parseJSON<T extends JSONValue>(text: string): T {7 return JSON.parse(text) as T;8}9
10type APIResponse = {11 data: {12 users: Array<{13 id: string;14 permissions: Array<'read' | 'write' | 'admin'>;15 }>;16 meta: {17 page: number;18 totalPages: number;19 };20 };21 status: 'success' | 'error';22};23
24// Now this is fully type-safe:25const response = parseJSON<APIResponse>('{"data":{"users":[]},"status":"success"}');26// response.data.users[0].permissions[0] is typed as 'read' | 'write' | 'admin'
But recursive types can go further. Let's model an arbitrarily nested filesystem:
1type FileSystem<T = string> = {2 name: string;3 content?: T;4 children?: FileSystem<T>[];5};6
7// We can now represent folders with arbitrary nesting8const myProject: FileSystem = {9 name: 'project',10 children: [11 {12 name: 'src',13 children: [14 { name: 'index.ts', content: 'console.log("Hello");' },15 {16 name: 'components',17 children: [18 { name: 'Button.tsx', content: 'export const Button = () => <button />;' }19 ]20 }21 ]22 },23 { name: 'README.md', content: '# My Project' }24 ]25};26
27// Let's create a function to find a file by path28function findByPath<T>(29 fs: FileSystem<T>,30 path: string[]31): FileSystem<T> | undefined {32 if (path.length === 0) return fs;33 if (!fs.children) return undefined;34
35 const [head, ...rest] = path;36 const child = fs.children.find(c => c.name === head);37 return child ? findByPath(child, rest) : undefined;38}39
40// Usage:41const component = findByPath(myProject, ['src', 'components', 'Button.tsx']);42// component?.content is now typed as string
When working with recursive types, watch out for the TypeScript compiler's recursion limits. Breaking complex types into smaller units often helps with both readability and compiler performance.
Discrete logic gates
Let's implement AND/OR/NOT/XOR at the type level:
1type And<A extends boolean, B extends boolean> =2 A extends true ? (B extends true ? true : false) : false;3
4type Or<A extends boolean, B extends boolean> =5 A extends true ? true : (B extends true ? true : false);6
7type Not<A extends boolean> = A extends true ? false : true;8
9type Xor<A extends boolean, B extends boolean> =10 Or<And<A, Not<B>>, And<Not<A>, B>>;11
12// Type-level logic in action13type T1 = And<true, false>; // false14type T2 = Or<true, false>; // true15type T3 = Xor<true, true>; // false
These compile-time logic gates let us build more sophisticated type-level computations. For example, we can implement a type that only allows objects with a specific combination of properties:
1// A type that requires either both 'id' and 'name' or neither2type EntityOptions<T> =3 T extends { id: any, name: any } ? T :4 T extends { id: any } ? never :5 T extends { name: any } ? never : T;6
7// Valid: has both id and name8const entity1: EntityOptions<{ id: string; name: string }> = { id: '1', name: 'Entity' };9
10// Valid: has neither11const entity2: EntityOptions<{ description: string }> = { description: 'Just a description' };12
13// Error: has id but not name14// const entity3: EntityOptions<{ id: string; description: string }> = { id: '1', description: 'Invalid' };
Template literal type wizardry
Template literal types go way beyond simple string concatenation. Let's build a type-safe URL pattern matching system:
1type ExtractParams<P extends string> =2 P extends `${infer Start}:${infer Param}/${infer Rest}` ?3 Param | ExtractParams<Rest> :4 P extends `${infer Start}:${infer Param}` ?5 Param :6 never;7
8type RouteParams<Route extends string> = {9 [K in ExtractParams<Route>]: string10};11
12// Usage13type UserProfileRoute = RouteParams<'/users/:userId/posts/:postId'>;14// Results in: { userId: string; postId: string }
Now let's build a fully type-safe router function:
1function navigate<T extends string>(2 route: T,3 params: RouteParams<T>4): string {5 let path = route;6 (Object.keys(params) as Array<keyof RouteParams<T>>).forEach(key => {7 path = path.replace(`:${key as string}`, params[key]);8 });9 return path;10}11
12// TypeScript knows exactly which params we need!13const path = navigate('/users/:userId/posts/:postId', {14 userId: '123',15 postId: '456'16}); // '/users/123/posts/456'
Template literal types combined with recursive type extraction are incredibly useful for type-safe string manipulation patterns.
Cross-framework type patterns
Most of the time, you'll be using TypeScript in a framework or library context. Let's look at how to make the most of TypeScript's features in each of those frameworks in some byte-sized examples.
React's component props pattern
Let's see how to achieve perfect prop typing in React:
1// A utility for creating type-safe components with required vs optional props2function createComponent<RequiredProps extends object, OptionalProps extends object = {}>(3 component: React.FC<RequiredProps & Partial<OptionalProps>>,4 defaultProps: OptionalProps5) {6 (component as any).defaultProps = defaultProps;7 return component as React.FC<RequiredProps & Partial<OptionalProps>>;8}9
10// Usage11const Button = createComponent<12 { onClick: () => void }, // Required props13 { variant: 'primary' | 'secondary' | 'danger' } // Optional props with defaults14>(15 ({ onClick, variant }) => (16 <button17 onClick={onClick}18 className={`btn btn-${variant}`}19 >20 Click me21 </button>22 ),23 { variant: 'primary' } // Default values24);25
26// Now TypeScript knows:27// <Button /> // Error: missing onClick28// <Button onClick={() => {}} /> // Works! variant is optional29// <Button onClick={() => {}} variant="danger" /> // Works!30// <Button onClick={() => {}} variant="invalid" /> // Error: invalid variant
Vue's fully typed composition API
1// Type-safe Vue component with complex props and emits2import { defineComponent, computed, ref } from 'vue';3
4export default defineComponent({5 props: {6 initialCount: { type: Number, required: true },7 step: { type: Number, default: 1 }8 },9 emits: {10 // Type checking for emitted events with payload validation11 'update:count': (count: number) => count >= 0,12 'threshold-reached': (count: number, threshold: number) => count >= threshold13 },14 setup(props, { emit }) {15 const count = ref(props.initialCount);16
17 const increment = () => {18 count.value += props.step;19 emit('update:count', count.value);20
21 if (count.value >= 100) {22 emit('threshold-reached', count.value, 100);23 }24 };25
26 const isEven = computed(() => count.value % 2 === 0);27
28 return {29 count,30 increment,31 isEven32 };33 }34});
Svelte's typed actions and stores
1// Type-safe Svelte store with derived values2import { writable, derived } from 'svelte/store';3
4// Create a type-safe writable store creator5function typedWritable<T>(initial: T) {6 const { subscribe, set, update } = writable<T>(initial);7 return {8 subscribe,9 set,10 update: (fn: (val: T) => T) => update(fn),11 // Type-safe reset12 reset: () => set(initial)13 };14}15
16// Usage17interface User {18 id: string;19 name: string;20 email: string;21 role: 'admin' | 'user';22}23
24// Create our stores25const user = typedWritable<User | null>(null);26const isLoggedIn = derived(user, $user => $user !== null);27const isAdmin = derived(user, $user => $user?.role === 'admin');28
29// Type-safe Svelte action30function longpress<T extends HTMLElement>(31 node: T,32 options: { duration: number; callback: () => void }33): { destroy: () => void } {34 let timer: number;35
36 function handleMousedown() {37 timer = window.setTimeout(options.callback, options.duration);38 }39
40 function handleMouseup() {41 clearTimeout(timer);42 }43
44 node.addEventListener('mousedown', handleMousedown);45 node.addEventListener('mouseup', handleMouseup);46
47 return {48 destroy() {49 node.removeEventListener('mousedown', handleMousedown);50 node.removeEventListener('mouseup', handleMouseup);51 }52 };53}
Redux doesn't have to suck
Let's create a type-safe Redux-like state management system:
1// Define action types with a discriminated union2type Action =3 | { type: 'INCREMENT'; payload: number }4 | { type: 'DECREMENT'; payload: number }5 | { type: 'RESET' }6 | { type: 'SET_USER'; payload: { id: string; name: string } };7
8// State shape9interface State {10 count: number;11 user: { id: string; name: string } | null;12}13
14// Initial state15const initialState: State = {16 count: 0,17 user: null18};19
20// Type-safe action creators21const actionCreators = {22 increment: (amount: number): Action => ({23 type: 'INCREMENT',24 payload: amount25 }),26 decrement: (amount: number): Action => ({27 type: 'DECREMENT',28 payload: amount29 }),30 reset: (): Action => ({31 type: 'RESET'32 }),33 setUser: (id: string, name: string): Action => ({34 type: 'SET_USER',35 payload: { id, name }36 })37};38
39// Type-safe reducer40function reducer(state: State = initialState, action: Action): State {41 switch (action.type) {42 case 'INCREMENT':43 return { ...state, count: state.count + action.payload };44 case 'DECREMENT':45 return { ...state, count: state.count - action.payload };46 case 'RESET':47 return { ...state, count: 0 };48 case 'SET_USER':49 return { ...state, user: action.payload };50 default:51 // Exhaustive checking - if you add a new action type, you'll get a compile error52 const _exhaustiveCheck: never = action;53 return state;54 }55}56
57// Type-safe dispatch and selector hooks58function useAppDispatch() {59 return function dispatch(action: Action) {60 // Implementation depends on your framework61 };62}63
64function useAppSelector<Selected>(65 selector: (state: State) => Selected66): Selected {67 // Implementation depends on your framework68 return selector(initialState);69}70
71// Usage72const CounterComponent = () => {73 const dispatch = useAppDispatch();74 const count = useAppSelector(state => state.count);75
76 return (77 <div>78 <p>Count: {count}</p>79 <button onClick={() => dispatch(actionCreators.increment(1))}>80 Increment81 </button>82 </div>83 );84};
Even on the server side
Let's create a fully type-safe API client with automatic type inference from endpoints:
1// API endpoint definitions2interface APIEndpoints {3 '/users': {4 get: {5 response: User[];6 query: { role?: 'admin' | 'user' };7 };8 post: {9 body: { name: string; email: string; role: 'admin' | 'user' };10 response: { id: string; success: boolean };11 };12 };13 '/users/:id': {14 get: {15 params: { id: string };16 response: User;17 };18 patch: {19 params: { id: string };20 body: Partial<{ name: string; email: string }>;21 response: User;22 };23 delete: {24 params: { id: string };25 response: { success: boolean };26 };27 };28}29
30// Type magic to get params from path31type ExtractParams<T extends string> =32 T extends `${infer _}:${infer Param}/${infer Rest}` ?33 { [K in Param]: string } & ExtractParams<Rest> :34 T extends `${infer _}:${infer Param}` ?35 { [K in Param]: string } :36 {};37
38// Dynamic type builder for API calls39type APICall<40 Path extends keyof APIEndpoints,41 Method extends keyof APIEndpoints[Path]42> = {43 path: Path;44 method: Method;45 params?: ExtractParams<Path> &46 ('params' extends keyof APIEndpoints[Path][Method] ?47 APIEndpoints[Path][Method]['params'] : {});48 query?: 'query' extends keyof APIEndpoints[Path][Method] ?49 APIEndpoints[Path][Method]['query'] : never;50 body?: 'body' extends keyof APIEndpoints[Path][Method] ?51 APIEndpoints[Path][Method]['body'] : never;52};53
54// Response type extractor55type APIResponse<56 Path extends keyof APIEndpoints,57 Method extends keyof APIEndpoints[Path]58> = 'response' extends keyof APIEndpoints[Path][Method] ?59 APIEndpoints[Path][Method]['response'] : never;60
61// Universal API client function with perfect type inference62async function apiCall<63 Path extends keyof APIEndpoints,64 Method extends keyof APIEndpoints[Path]65>(options: APICall<Path, Method>): Promise<APIResponse<Path, Method>> {66 const { path, method, params = {}, query = {}, body } = options;67
68 // Replace path params69 let url = path as string;70 Object.entries(params || {}).forEach(([key, value]) => {71 url = url.replace(`:${key}`, value);72 });73
74 // Add query string75 if (Object.keys(query || {}).length) {76 url += '?' + new URLSearchParams(query as Record<string, string>).toString();77 }78
79 // Make the fetch request80 const response = await fetch(url, {81 method: method as string,82 headers: {83 'Content-Type': 'application/json',84 },85 body: body ? JSON.stringify(body) : undefined,86 });87
88 if (!response.ok) {89 throw new Error(`API error: ${response.status}`);90 }91
92 return response.json();93}94
95// Usage - with perfect type inference96async function exampleUsage() {97 // Get all users - TypeScript knows the response type is User[]98 const users = await apiCall({99 path: '/users',100 method: 'get',101 query: { role: 'admin' } // TypeScript validates this102 });103
104 // Get one user - TypeScript knows the response type is User105 const user = await apiCall({106 path: '/users/:id',107 method: 'get',108 params: { id: '123' } // TypeScript requires this109 });110
111 // Update a user - TypeScript validates the body shape112 const updated = await apiCall({113 path: '/users/:id',114 method: 'patch',115 params: { id: '123' },116 body: { name: 'New Name' } // TypeScript validates this117 });118}
Type augmentation is a thing
Sometimes we need to enhance or fix third-party types. Let's see proper module augmentation:
1// Fix missing property in a third-party library2declare module 'some-untyped-module' {3 export interface Options {4 timeout?: number;5 retries?: number;6 }7
8 export function doSomething(options?: Options): Promise<string>;9}10
11// Add custom properties to existing DOM interfaces12declare global {13 interface HTMLElement {14 customData: {15 initialized: boolean;16 version: string;17 };18 }19}20
21// Usage22import { doSomething } from 'some-untyped-module';23
24// TypeScript now knows these options exist25doSomething({26 timeout: 1000,27 retries: 328});29
30// And our custom DOM property31document.getElementById('app')!.customData = {32 initialized: true,33 version: '1.0.0'34};
Perf is everything
Let's talk about real-world type performance issues I've encountered:
1// BAD: Creates massive intersection types that slow compilation2type DeepPartial<T> = {3 [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];4};5
6// BETTER: Uses conditional types with constraints7type DeepPartial<T> = T extends object ? {8 [P in keyof T]?: DeepPartial<T[P]>;9} : T;10
11// BAD: Recursively infers tuple types (explodes compile time)12type TupleToUnion<T extends any[]> = T[number];13type Flatten<T extends any[]> =14 T extends [infer F, ...infer R]15 ? [...(F extends any[] ? Flatten<F> : [F]), ...Flatten<R>]16 : [];17
18// BETTER: Uses array methods at runtime instead19function flatten<T>(array: T[]): T[] {20 return array.flat(Infinity) as T[];21}22
23// BAD: Excessive generics in React components24type TableProps<25 T extends Record<string, any>,26 K extends keyof T = keyof T,27 S extends K = K28> = {29 data: T[];30 columns: K[];31 sortBy?: S;32 sortDirection?: 'asc' | 'desc';33 onSort?: (column: S) => void;34 // 20 more complicated generic props35};36
37// BETTER: Simpler generics, focused constraints38type SortableKeys<T> = {39 [K in keyof T]: T[K] extends number | string | Date ? K : never40}[keyof T];41
42type TableProps<T extends Record<string, any>> = {43 data: T[];44 columns: Array<keyof T>;45 sortBy?: SortableKeys<T>;46 sortDirection?: 'asc' | 'desc';47 onSort?: (column: SortableKeys<T>) => void;48};
Complex recursive types can dramatically slow down TypeScript compilation time. Always test type performance on large real-world objects and break down complex types into simpler chunks.
Is this worth stressing over
Maybe? Because at the end of the day, TypeScript isn't just about catching null errors; it's a legit type system that can encode business logic, prevent entire classes of bugs, and make refactoring large codebases actually possible.
But remember, its always been about simplicity first and foremost.
Just because you can implement complex compile-time type logic doesn't mean you always should. Sometimes a simple runtime check with a helpful error message beats a complex type maze.
I like to say that the best TypeScript code is invisible. It just works, catches errors early, and provides a delightful developer experience without getting in your way. The patterns I've shared today are tools, not rules.
Use them when they solve real problems, not to flex on PR reviews with your teammates.
If this post made your brain hurt, congratulations! You're pushing into the exciting territory where type systems and programming languages converge. To most of us, including me, TypeScript started as "JSDoc but bougie" but since then it really has evolved into something so powerful.