~/nyuma.dev

TypeScript can be sexy to work with

Because `any` was never the answer to your hundreds of TypeScript questions

15 mins read

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) extends
3 (<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.

To answer the question...

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.

Tip

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 nesting
8const 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 path
28function 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
Warning

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 action
13type T1 = And<true, false>; // false
14type T2 = Or<true, false>; // true
15type 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 neither
2type 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 name
8const entity1: EntityOptions<{ id: string; name: string }> = { id: '1', name: 'Entity' };
9
10// Valid: has neither
11const entity2: EntityOptions<{ description: string }> = { description: 'Just a description' };
12
13// Error: has id but not name
14// 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>]: string
10};
11
12// Usage
13type 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'
Tip

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 props
2function createComponent<RequiredProps extends object, OptionalProps extends object = {}>(
3 component: React.FC<RequiredProps & Partial<OptionalProps>>,
4 defaultProps: OptionalProps
5) {
6 (component as any).defaultProps = defaultProps;
7 return component as React.FC<RequiredProps & Partial<OptionalProps>>;
8}
9
10// Usage
11const Button = createComponent<
12 { onClick: () => void }, // Required props
13 { variant: 'primary' | 'secondary' | 'danger' } // Optional props with defaults
14>(
15 ({ onClick, variant }) => (
16 <button
17 onClick={onClick}
18 className={`btn btn-${variant}`}
19 >
20 Click me
21 </button>
22 ),
23 { variant: 'primary' } // Default values
24);
25
26// Now TypeScript knows:
27// <Button /> // Error: missing onClick
28// <Button onClick={() => {}} /> // Works! variant is optional
29// <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 emits
2import { 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 validation
11 'update:count': (count: number) => count >= 0,
12 'threshold-reached': (count: number, threshold: number) => count >= threshold
13 },
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 isEven
32 };
33 }
34});

Svelte's typed actions and stores

1// Type-safe Svelte store with derived values
2import { writable, derived } from 'svelte/store';
3
4// Create a type-safe writable store creator
5function 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 reset
12 reset: () => set(initial)
13 };
14}
15
16// Usage
17interface User {
18 id: string;
19 name: string;
20 email: string;
21 role: 'admin' | 'user';
22}
23
24// Create our stores
25const 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 action
30function 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 union
2type 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 shape
9interface State {
10 count: number;
11 user: { id: string; name: string } | null;
12}
13
14// Initial state
15const initialState: State = {
16 count: 0,
17 user: null
18};
19
20// Type-safe action creators
21const actionCreators = {
22 increment: (amount: number): Action => ({
23 type: 'INCREMENT',
24 payload: amount
25 }),
26 decrement: (amount: number): Action => ({
27 type: 'DECREMENT',
28 payload: amount
29 }),
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 reducer
40function 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 error
52 const _exhaustiveCheck: never = action;
53 return state;
54 }
55}
56
57// Type-safe dispatch and selector hooks
58function useAppDispatch() {
59 return function dispatch(action: Action) {
60 // Implementation depends on your framework
61 };
62}
63
64function useAppSelector<Selected>(
65 selector: (state: State) => Selected
66): Selected {
67 // Implementation depends on your framework
68 return selector(initialState);
69}
70
71// Usage
72const 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 Increment
81 </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 definitions
2interface 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 path
31type 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 calls
39type 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 extractor
55type 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 inference
62async 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 params
69 let url = path as string;
70 Object.entries(params || {}).forEach(([key, value]) => {
71 url = url.replace(`:${key}`, value);
72 });
73
74 // Add query string
75 if (Object.keys(query || {}).length) {
76 url += '?' + new URLSearchParams(query as Record<string, string>).toString();
77 }
78
79 // Make the fetch request
80 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 inference
96async 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 this
102 });
103
104 // Get one user - TypeScript knows the response type is User
105 const user = await apiCall({
106 path: '/users/:id',
107 method: 'get',
108 params: { id: '123' } // TypeScript requires this
109 });
110
111 // Update a user - TypeScript validates the body shape
112 const updated = await apiCall({
113 path: '/users/:id',
114 method: 'patch',
115 params: { id: '123' },
116 body: { name: 'New Name' } // TypeScript validates this
117 });
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 library
2declare 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 interfaces
12declare global {
13 interface HTMLElement {
14 customData: {
15 initialized: boolean;
16 version: string;
17 };
18 }
19}
20
21// Usage
22import { doSomething } from 'some-untyped-module';
23
24// TypeScript now knows these options exist
25doSomething({
26 timeout: 1000,
27 retries: 3
28});
29
30// And our custom DOM property
31document.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 compilation
2type DeepPartial<T> = {
3 [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
4};
5
6// BETTER: Uses conditional types with constraints
7type 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 instead
19function flatten<T>(array: T[]): T[] {
20 return array.flat(Infinity) as T[];
21}
22
23// BAD: Excessive generics in React components
24type TableProps<
25 T extends Record<string, any>,
26 K extends keyof T = keyof T,
27 S extends K = K
28> = {
29 data: T[];
30 columns: K[];
31 sortBy?: S;
32 sortDirection?: 'asc' | 'desc';
33 onSort?: (column: S) => void;
34 // 20 more complicated generic props
35};
36
37// BETTER: Simpler generics, focused constraints
38type SortableKeys<T> = {
39 [K in keyof T]: T[K] extends number | string | Date ? K : never
40}[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};
Warning

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.

Note

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.