TypeScript Types Are Interface Copy You Cannot See
Type names, property names, and generic constraints are UX writing. Most are written carelessly — and users of your library pay for every mistake.
TypeScript types are the interface copy your users read while they work. The name of a type shows up in the autocomplete tooltip. The names of its properties appear in the editor sidebar. The error message from a failed generic constraint is the first thing a developer reads when their code does not compile. This is UX writing — written in type syntax instead of English, but still read, interpreted, and misunderstood in exactly the same way. Most of it is written carelessly.
How a type name teaches usage
Compare two type signatures for the same function:
// Version A
function transform<T, U>(input: T, fn: (val: T) => U): U
// Version B
function transform<Input, Output>(input: Input, fn: (val: Input) => Output): Output
Both are functionally identical. But in version A, hovering over the function in your editor shows <T, U> — meaningless without context. In version B, hovering shows <Input, Output> — the relationship is self-evident. A function that transforms one shape into another. T requires you to remember what T means in this context. Input requires no memory.
This matters more in complex APIs. A library's QueryOptions<T, E, D, Q> is a puzzle. A library's QueryOptions<TData, TError, TSelect, TQueryKey> tells you exactly what each slot is for. TanStack Query uses the longer names. This is why their type errors are comprehensible.
JSDoc becomes the editor tooltip
Adding JSDoc to types is free documentation that shows up in the editor without opening a browser:
/**
* Configuration for the file upload hook.
*
* @example
* const uploader = useFileUpload({
* endpoint: '/api/upload',
* maxSizeMB: 5,
* onSuccess: (url) => setAvatarUrl(url)
* })
*/
type UseFileUploadOptions = {
/** The API endpoint to POST files to */
endpoint: string
/** Maximum file size in megabytes. Defaults to 10. */
maxSizeMB?: number
/** Called with the uploaded file URL on success */
onSuccess?: (url: string) => void
/** Called with the Error if the upload fails */
onError?: (error: Error) => void
}
Every property comment becomes the tooltip when a developer hovers over that property in their editor. The example in the type-level JSDoc becomes the example shown in IDE "Quick Info" panels. This is documentation that is guaranteed to be co-located with the API — it cannot go stale in a separate docs site.
Discriminated unions are self-documenting
Optional fields everywhere is the type system equivalent of a string with twenty if checks:
// Unclear — which combinations are valid?
type QueryState<T> = {
data?: T
error?: Error
isLoading: boolean
isSuccess: boolean
isError: boolean
}
A discriminated union encodes the valid states explicitly:
type QueryState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'error'; error: Error }
| { status: 'success'; data: T }
Now the type is its own documentation. When you narrow on status === 'success', TypeScript knows data is present and T, not T | undefined. When you narrow on status === 'error', TypeScript knows error is present. There is no combination of isLoading: true and isSuccess: true to reason about.
The discriminant property (status) is also a naming decision. status is better than type (overloaded word) and better than phase (domain-specific). It reads correctly in a switch statement: switch (state.status).
How a generic constraint becomes an error message
When a generic constraint fails, the error message is the type name:
// Bad constraint
function createTable<T extends Record<string, any>>(rows: T[]): Table<T>
// The error: "Type 'string' does not satisfy the constraint 'Record<string, any>'"
// The user sees: ???
// Better constraint with a descriptive type alias
type TableRow = Record<string, string | number | boolean | null>
function createTable<Row extends TableRow>(rows: Row[]): Table<Row>
// The error: "Type 'Date' does not satisfy the constraint 'TableRow'"
// The user now knows: their row data contains a Date, which is not a valid cell type
The error message surface area is the constraint type's name. TableRow is informative. Record<string, any> requires parsing. When writing generic constraints, name the constraint type explicitly — it pays off in every error message.
The type that taught me this
react-hook-form's FieldValues generic is a good example of types-as-documentation done right. The generic name tells you what it represents. The Path<T> utility type, which gives you a dot-notation string of valid field paths in a form schema, produces error messages like "Type 'user.naem' is not assignable to Path<FormData>" — which immediately tells you there is a typo in the field name, not a type system failure. The type name in the error message is a breadcrumb back to the mistake. That is intentional API design expressed entirely through type naming.