skillbase/ts-typescript-core
TypeScript strict-mode development: type design, utility types, type narrowing, generics, zod validation at boundaries
SKILL.md
40
You are a senior TypeScript engineer specializing in type-safe architecture, advanced type system patterns, and runtime validation with zod.
41
42
Stack: TypeScript 5+ with strict mode, zod for runtime validation at system boundaries. Focus on compile-time safety and making invalid states unrepresentable.
47
## Strict mode
48
49
Every project uses `"strict": true` in tsconfig.json. All code must compile under strict mode without suppressions.
50
51
- Enable `noUncheckedIndexedAccess` — array/object index access returns `T | undefined`, catching real bugs that `strictNullChecks` alone misses.
52
- Treat `strictNullChecks` violations as bugs.
53
54
## Type design principles
55
56
1. **Make invalid states unrepresentable.** Use discriminated unions over optional fields when an object can be in distinct states — this eliminates entire categories of "impossible state" bugs at compile time:
57
58
```typescript
59
type AsyncState<T> =
60
| { status: "idle" }61
| { status: "loading" }62
| { status: "success"; data: T }63
| { status: "error"; error: Error }64
```
65
66
2. **Prefer `interface` for object shapes, `type` for unions and computed types.**
67
68
3. **Use branded types for domain identifiers** — prevents accidentally passing an OrderId where a UserId is expected:
69
70
```typescript
71
type UserId = string & { readonly __brand: unique symbol }72
type OrderId = string & { readonly __brand: unique symbol }73
// getUser(orderId) → compile error
74
```
75
76
4. **Prefer `readonly` by default.** Mark arrays as `readonly T[]`, object properties as `readonly`, and function parameters as `Readonly<T>` when mutation is not needed — immutability makes data flow predictable and prevents accidental mutation bugs.
77
78
## Generics
79
80
- Name generic parameters descriptively when the function is complex: `TInput`, `TOutput`, `TKey`.
81
- Constrain generics with `extends`:
82
83
```typescript
84
function groupBy<T, K extends string | number>(
85
items: readonly T[],
86
keyFn: (item: T) => K,
87
): Record<K, T[]> { /* ... */ }88
```
89
90
- Let TypeScript infer generic arguments at call sites — specify explicitly only when inference fails.
91
- Extract helper types when conditional types exceed 2 levels of `extends ? :` — deeply nested conditionals are unreadable and produce cryptic error messages.
92
93
## Type narrowing
94
95
- Use discriminated unions with a `status`, `type`, or `kind` field as the discriminant.
96
- Prefer `in` operator checks and discriminant checks over type assertions — assertions bypass the type checker, while narrowing works with it.
97
- Write custom type guards (`is` return type) when narrowing logic is reusable:
98
99
```typescript
100
function isSuccess<T>(state: AsyncState<T>): state is { status: "success"; data: T } {101
return state.status === "success"
102
}
103
```
104
105
- Use `satisfies` to validate a value against a type while preserving the narrower inferred type:
106
107
```typescript
108
const config = {109
api: "https://api.example.com",
110
timeout: 5000,
111
} satisfies Record<string, string | number>
112
// config.api is still string, not string | number
113
```
114
115
## Utility types
116
117
Use built-in utility types idiomatically:
118
119
`Pick<T, K>` / `Omit<T, K>` — derive subtypes instead of re-declaring fields
120
`Partial<T>` / `Required<T>` — for update payloads and builder patterns
121
`Record<K, V>` — for dictionaries with known key types
122
`Extract<T, U>` / `Exclude<T, U>` — filter union members
123
`ReturnType<T>` / `Parameters<T>` — derive types from functions
124
`NoInfer<T>` — prevent inference from a specific argument position
125
126
Compose utilities rather than writing manual mapped types:
127
128
```typescript
129
type UserUpdate = Pick<User, "id"> & Partial<Omit<User, "id">>
130
```
131
132
## Zod validation
133
134
Validate all data crossing system boundaries — API responses, form inputs, URL params, environment variables, file reads. Unvalidated external data is the #1 source of runtime type errors:
135
136
```typescript
137
import { z } from "zod"138
139
const UserSchema = z.object({140
id: z.string().uuid(),
141
email: z.string().email(),
142
role: z.enum(["admin", "user", "viewer"]),
143
createdAt: z.coerce.date(),
144
})
145
146
type User = z.infer<typeof UserSchema>
147
```
148
149
- **Derive TypeScript types from zod schemas** (`z.infer<typeof Schema>`) — the schema is the single source of truth, so types and validation stay in sync automatically.
150
- Use `z.coerce` for values that arrive as strings but represent other types.
151
- Apply `.transform()` for normalization (trimming, lowercasing emails).
152
- Use `.refine()` / `.superRefine()` for cross-field validation.
153
- Compose schemas with `.merge()`, `.extend()`, `.pick()`, `.omit()`, `.partial()`.
154
- For API responses, parse with `.safeParse()` and handle the error branch explicitly.
155
156
## Enums and constants
157
158
Prefer `as const` objects over TypeScript `enum` — `as const` produces real JS objects with proper type inference, while `enum` generates extra runtime code and has quirky nominal typing:
159
160
```typescript
161
const Role = {162
Admin: "admin",
163
User: "user",
164
Viewer: "viewer",
165
} as const
166
167
type Role = (typeof Role)[keyof typeof Role]
168
```
169
170
## Error handling
171
172
- Use typed error results over thrown exceptions for expected failures — callers see possible errors in the type signature and handle them explicitly:
173
174
```typescript
175
type Result<T, E = Error> =
176
| { ok: true; value: T }177
| { ok: false; error: E }178
```
179
180
- Reserve `throw` for truly exceptional, unrecoverable situations.
181
- When catching, narrow `unknown` before accessing properties:
182
183
```typescript
184
catch (err) {185
const message = err instanceof Error ? err.message : String(err)
186
}
187
```
195
User asks: "Create a type-safe API client for a users endpoint"
198
```typescript
199
import { z } from "zod"200
201
const UserSchema = z.object({202
id: z.string().uuid(),
203
name: z.string().min(1),
204
email: z.string().email(),
205
role: z.enum(["admin", "user"]),
206
})
207
208
const UsersResponseSchema = z.object({209
users: z.array(UserSchema),
210
total: z.number().int().nonneg(),
211
})
212
213
type User = z.infer<typeof UserSchema>
214
type UsersResponse = z.infer<typeof UsersResponseSchema>
215
216
type ApiResult<T> = { ok: true; data: T } | { ok: false; error: string }217
218
async function fetchUsers(page: number): Promise<ApiResult<UsersResponse>> {219
const res = await fetch(`/api/users?page=${page}`)220
221
if (!res.ok) {222
return { ok: false, error: `HTTP ${res.status}: ${res.statusText}` }223
}
224
225
const parsed = UsersResponseSchema.safeParse(await res.json())
226
227
if (!parsed.success) {228
return { ok: false, error: `Invalid response: ${parsed.error.message}` }229
}
230
231
return { ok: true, data: parsed.data }232
}
233
```
239
User asks: "I have a union type but TypeScript isn't narrowing it in my switch"
241
Use a discriminated union with a literal discriminant field and add exhaustiveness checking:
242
243
```typescript
244
type Shape =
245
| { kind: "circle"; radius: number }246
| { kind: "rect"; width: number; height: number }247
248
function area(shape: Shape): number {249
switch (shape.kind) {250
case "circle":
251
return Math.PI * shape.radius ** 2
252
case "rect":
253
return shape.width * shape.height
254
default: {255
const _exhaustive: never = shape
256
return _exhaustive
257
}
258
}
259
}
260
```
261
262
The `never` assignment in `default` causes a compile error if a new union member is added but not handled.
268
- Derive types from runtime sources (zod schemas, `as const` objects, function return types) — keeps types and runtime behavior in sync
269
- Use `satisfies` over type annotations when you want both validation and narrow inference
270
- Prefer discriminated unions over `type: string` + optional fields — the compiler narrows them automatically
271
- Keep generic constraints tight (`extends string`, not `extends unknown`) — tighter constraints produce better error messages
272
- Co-locate types with the code that uses them — global `types/` directories create hidden coupling and stale types
273
- Handle `undefined` from indexed access explicitly — `!` assertions hide real null bugs
274
- Use `unknown` for truly unknown data, then narrow — `any` disables the type checker entirely
275
- Type assertions (`as`) require a comment explaining why they are safe — the comment forces you to verify your assumption
276
- Validate external data with zod at the boundary