
Writing Typescript
- 99 installs
- 6 repo stars
- Updated July 22, 2026
- julianobarbosa/claude-code-skills
Idiomatic TypeScript development. Use when writing TypeScript code, Node.js services, React apps, discussing TS patterns.
About
Idiomatic TypeScript development.. Use for TypeScript writing, Node.js services, React apps, pattern discussion.
- intermediate skill
- core: frontend development
Writing Typescript by the numbers
- 99 all-time installs (skills.sh)
- +1 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #1,056 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/julianobarbosa/claude-code-skills --skill writing-typescriptAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 99 |
|---|---|
| repo stars | ★ 6 |
| Last updated | July 22, 2026 |
| Repository | julianobarbosa/claude-code-skills ↗ |
What it does
Idiomatic TypeScript development. Use when writing TypeScript code, Node.js services, React apps, discussing TS patterns.
Files
TypeScript Development (2025)
Core Principles
- Strict typing: Enable all strict checks
- Parse, don't validate: Transform untrusted data at boundaries
- Composition over inheritance: Small, focused functions
- Explicit over implicit: No
any, preferunknown
Toolchain
bun # Runtime + package manager (fast)
vite # Frontend bundling
vitest # Testing
eslint # Linting
prettier # FormattingQuick Patterns
Type Guards
function isUser(value: unknown): value is User {
return typeof value === "object" && value !== null && "id" in value;
}Discriminated Unions
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
function processResult<T>(result: Result<T>): T {
if (result.ok) return result.value;
throw result.error;
}Utility Types
type UserUpdate = Partial<User>;
type UserSummary = Pick<User, "id" | "name">;
type UserWithoutPassword = Omit<User, "password">;
type ReadonlyUser = Readonly<User>;tsconfig.json Essentials
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"isolatedModules": true
}
}References
- PATTERNS.md - Code patterns and style
- REACT.md - React component patterns
- TESTING.md - Testing with vitest
Commands
bun install # Install deps
bun run build # Build
bun test # Test
bun run lint # Lint
bun run format # Format---
Gotchas
- `import type` with `verbatimModuleSyntax` and named exports: type-only import of a value triggers errors. Fix:
import { type Foo } from "mod"(inline marker). - `as const` on object: readonly AND literal-narrowed — different from
as Foo(asserts type) orsatisfies Foo(validates without widening). - `satisfies` validates without widening; `as` widens — using
aswhere you wantedsatisfiesloses literal types silently. - `Array<T>.includes(x)` requires `x` to be of type `T` — narrowing-from-union doesn't work; the standard fix is a type-predicate helper.
- `strictNullChecks: false` makes `T` mean `T | null | undefined` for ALL types — partial migrations leave types that lie about nullability.
- `tsconfig.json` `extends` doesn't recursively merge `compilerOptions.paths` — child paths REPLACE parent paths, not merge.
TypeScript Patterns Reference
Project Structure
src/
├── domain/ # Business logic, entities
├── application/ # Use cases, services
├── infrastructure/ # External integrations
└── presentation/ # HTTP handlers, UI
tests/
tsconfig.json
package.jsonType Patterns
Avoid any, Use unknown
// Bad
function parse(data: any): User { ... }
// Good
function parse(data: unknown): User {
if (!isUser(data)) throw new Error("Invalid user data");
return data;
}Discriminated Unions
type RequestState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
function render<T>(state: RequestState<T>) {
switch (state.status) {
case "idle":
return null;
case "loading":
return <Spinner />;
case "success":
return <Data data={state.data} />;
case "error":
return <Error error={state.error} />;
}
}Type Guards
function isString(value: unknown): value is string {
return typeof value === "string";
}
function hasId(value: unknown): value is { id: string } {
return typeof value === "object" && value !== null && "id" in value;
}Generic Constraints
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
type WithId = { id: string };
function updateById<T extends WithId>(
items: T[],
id: string,
update: Partial<T>,
): T[] {
return items.map((item) => (item.id === id ? { ...item, ...update } : item));
}Validation (Zod)
import { z } from "zod";
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(1),
role: z.enum(["admin", "user"]),
});
type User = z.infer<typeof UserSchema>;
function parseUser(data: unknown): User {
return UserSchema.parse(data);
}Error Handling
Result Type
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
async function fetchUser(id: string): Promise<Result<User, ApiError>> {
try {
const response = await fetch(`/users/${id}`);
if (!response.ok) return err(new ApiError(response.status));
return ok(await response.json());
} catch (e) {
return err(new ApiError(500, e));
}
}Custom Errors
class AppError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly statusCode: number = 500,
) {
super(message);
this.name = "AppError";
}
}
class NotFoundError extends AppError {
constructor(resource: string, id: string) {
super(`${resource} not found: ${id}`, "NOT_FOUND", 404);
}
}Async Patterns
Concurrent Requests
async function fetchAll<T>(urls: string[]): Promise<T[]> {
const results = await Promise.all(
urls.map((url) => fetch(url).then((r) => r.json())),
);
return results;
}
async function fetchAllSettled<T>(urls: string[]): Promise<Result<T>[]> {
const results = await Promise.allSettled(
urls.map((url) => fetch(url).then((r) => r.json())),
);
return results.map((r) =>
r.status === "fulfilled" ? ok(r.value) : err(r.reason),
);
}Retry Logic
async function retry<T>(
fn: () => Promise<T>,
attempts: number,
delay: number,
): Promise<T> {
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (error) {
if (i === attempts - 1) throw error;
await new Promise((r) => setTimeout(r, delay * Math.pow(2, i)));
}
}
throw new Error("Unreachable");
}Configuration
const ConfigSchema = z.object({
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),
});
export const config = ConfigSchema.parse(process.env);Module Organization
Barrel Exports
// domain/index.ts
export { User } from "./user";
export { Product } from "./product";
export type { UserService } from "./user-service";Dependency Injection
interface Dependencies {
userRepo: UserRepository;
emailService: EmailService;
logger: Logger;
}
function createUserService(deps: Dependencies): UserService {
return {
async createUser(data: CreateUserInput) {
const user = await deps.userRepo.create(data);
await deps.emailService.sendWelcome(user.email);
deps.logger.info("User created", { userId: user.id });
return user;
},
};
}Style Guidelines
- Use
constby default,letwhen needed - Prefer
interfacefor object shapes - Use
typefor unions, intersections, mapped types - Export types explicitly
- No
any- useunknownand narrow - Prefer
readonlyfor immutable data
React TypeScript Patterns
Component Types
Function Components
interface ButtonProps {
label: string;
onClick: () => void;
disabled?: boolean;
}
function Button({ label, onClick, disabled = false }: ButtonProps) {
return (
<button onClick={onClick} disabled={disabled}>
{label}
</button>
);
}With Children
interface CardProps {
title: string;
children: React.ReactNode;
}
function Card({ title, children }: CardProps) {
return (
<div className="card">
<h2>{title}</h2>
{children}
</div>
);
}Generic Components
interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
keyExtractor: (item: T) => string;
}
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map((item) => (
<li key={keyExtractor(item)}>{renderItem(item)}</li>
))}
</ul>
);
}Hooks
Custom Hook Pattern
function useUser(userId: string) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetchUser(userId)
.then((data) => {
if (!cancelled) setUser(data);
})
.catch((err) => {
if (!cancelled) setError(err);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [userId]);
return { user, loading, error };
}useCallback with Types
const handleSubmit = useCallback(
async (data: FormData) => {
await submitForm(data);
onSuccess();
},
[onSuccess],
);useReducer
type State = { count: number; error: string | null };
type Action =
| { type: "increment" }
| { type: "decrement" }
| { type: "reset"; payload: number };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "increment":
return { ...state, count: state.count + 1 };
case "decrement":
return { ...state, count: state.count - 1 };
case "reset":
return { ...state, count: action.payload };
}
}Event Handling
function Form() {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
submit();
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") submit();
};
return (
<form onSubmit={handleSubmit}>
<input onChange={handleChange} onKeyDown={handleKeyDown} />
</form>
);
}Context
interface AuthContextValue {
user: User | null;
login: (credentials: Credentials) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
function useAuth() {
const context = useContext(AuthContext);
if (!context) throw new Error("useAuth must be used within AuthProvider");
return context;
}
function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const login = async (credentials: Credentials) => {
const user = await authenticate(credentials);
setUser(user);
};
const logout = () => setUser(null);
return <AuthContext.Provider value={{ user, login, logout }}>{children}</AuthContext.Provider>;
}Data Fetching (TanStack Query)
function useUsers() {
return useQuery({
queryKey: ["users"],
queryFn: fetchUsers,
});
}
function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createUser,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
},
});
}Form Handling (React Hook Form + Zod)
const FormSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
type FormData = z.infer<typeof FormSchema>;
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(FormSchema),
});
const onSubmit = (data: FormData) => {
login(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("email")} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register("password")} />
{errors.password && <span>{errors.password.message}</span>}
<button type="submit">Login</button>
</form>
);
}Performance
Memoization
const MemoizedComponent = memo(function ExpensiveComponent({ data }: Props) {
return <div>{/* expensive render */}</div>;
});
const memoizedValue = useMemo(() => computeExpensive(a, b), [a, b]);
const memoizedCallback = useCallback(() => doSomething(id), [id]);Lazy Loading
const Dashboard = lazy(() => import("./Dashboard"));
function App() {
return (
<Suspense fallback={<Loading />}>
<Dashboard />
</Suspense>
);
}TypeScript Testing Reference
Framework: Vitest
bun add -d vitest @testing-library/react @testing-library/jest-domUnit Tests
import { describe, it, expect } from "vitest";
describe("validateEmail", () => {
it("accepts valid email", () => {
expect(validateEmail("user@example.com")).toBe(true);
});
it("rejects empty string", () => {
expect(validateEmail("")).toBe(false);
});
it("rejects invalid format", () => {
expect(validateEmail("invalid")).toBe(false);
});
});Async Tests
describe("fetchUser", () => {
it("returns user data", async () => {
const user = await fetchUser("123");
expect(user.id).toBe("123");
});
it("throws on not found", async () => {
await expect(fetchUser("unknown")).rejects.toThrow("Not found");
});
});Mocking
import { vi, describe, it, expect, beforeEach } from "vitest";
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
describe("api", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("calls fetch with correct url", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: "123" }),
});
await getUser("123");
expect(mockFetch).toHaveBeenCalledWith("/api/users/123");
});
});Module Mocking
import { vi, describe, it, expect } from "vitest";
import { sendEmail } from "./email";
import { createUser } from "./user-service";
vi.mock("./email", () => ({
sendEmail: vi.fn(),
}));
describe("createUser", () => {
it("sends welcome email", async () => {
await createUser({ email: "test@example.com" });
expect(sendEmail).toHaveBeenCalledWith(
expect.objectContaining({ to: "test@example.com" }),
);
});
});React Component Tests
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
describe("Button", () => {
it("renders label", () => {
render(<Button label="Click me" onClick={() => {}} />);
expect(screen.getByText("Click me")).toBeInTheDocument();
});
it("calls onClick when clicked", () => {
const handleClick = vi.fn();
render(<Button label="Click" onClick={handleClick} />);
fireEvent.click(screen.getByRole("button"));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it("is disabled when disabled prop is true", () => {
render(<Button label="Click" onClick={() => {}} disabled />);
expect(screen.getByRole("button")).toBeDisabled();
});
});Hook Tests
import { renderHook, waitFor } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
describe("useUser", () => {
it("fetches user data", async () => {
vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: "123", name: "Test" }),
} as Response);
const { result } = renderHook(() => useUser("123"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.user).toEqual({ id: "123", name: "Test" });
});
});API Route Tests
import { describe, it, expect } from "vitest";
describe("POST /api/users", () => {
it("creates user with valid data", async () => {
const response = await app.request("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "test@example.com", name: "Test" }),
});
expect(response.status).toBe(201);
const data = await response.json();
expect(data.id).toBeDefined();
});
it("returns 400 for invalid data", async () => {
const response = await app.request("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "invalid" }),
});
expect(response.status).toBe(400);
});
});Coverage
vitest --coverage
vitest --coverage --coverage.thresholds.lines=80Configuration (vitest.config.ts)
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./tests/setup.ts"],
coverage: {
reporter: ["text", "html"],
exclude: ["node_modules/", "tests/"],
},
},
});Guidelines
- Test behavior, not implementation
- Use descriptive test names
- One assertion per test (when practical)
- Mock external dependencies
- Keep tests independent
Related skills
Frontend Developmentfrontend