Phoenix TypeScript Conventions
These conventions apply to all TypeScript in the Phoenix monorepo — the js/app/ frontend, the js/packages/ libraries (phoenix-client, phoenix-cli, phoenix-evals, phoenix-mcp, phoenix-otel, phoenix-config), examples, and benchmarks.
Before writing new code, explore the directory you're working in to understand existing patterns — then follow these rules.
Naming
Self-documenting names eliminate mental parsing for the next reader.
- Variables must not use single letters — even loop counters benefit from
index, row, char.
- Complex conditions should be extracted into named booleans so code reads as prose.
- Booleans must use verb prefixes:
isAllowed, hasError, canSubmit — not allowed, error.
- Function names must start with an action verb that describes what the function does:
getUser, normalizeTimestamp, logEvent, parseResponse, buildQuery — not user(), timestamp(), event().
// Bad — single letters and ambiguous names
for (let i = 0; i < s.length; i++) {
const d = s[i].ts - s[i - 1]?.ts;
const r = fn(s[i].v);
}
// Good — self-documenting
for (let index = 0; index < spans.length; index++) {
const elapsed = spans[index].timestamp - spans[index - 1]?.timestamp;
const result = normalizeValue(spans[index].value);
}
// Bad — boolean without verb prefix, condition inline
<Button isDisabled={!permission || submitting}>
// Good — named boolean with verb prefix
const isDisabled = !hasPermission || isSubmitting;
<Button isDisabled={isDisabled}>
Functions
- Functions with 2+ parameters should use object destructuring over positional args — this makes call sites readable and resilient to reordering.
- Object parameters should be documented with JSDoc using
@param dot notation so editors surface descriptions on hover and during autocomplete.
- Behavior should be built from composition (functions and hooks), not inheritance.
- Transforms should prefer functional purity over mutation — use
map not reduce for element-wise transforms, return new objects instead of mutating.
- Pure utilities must not reach for hardcoded module constants or ambient state (
Date.now(), new Date(), config singletons) deep inside their bodies. Expose every such value as an optional parameter whose default is the constant, so callers — and especially tests — can override it and the function's output depends only on its inputs. The module constant becomes the default, prefixed DEFAULT_.
const DEFAULT_ZOOM_FACTOR = 2;
const DEFAULT_MIN_WINDOW_MS = 60_000;
// Bad — reads a module constant and the clock internally; output isn't
// determined by inputs, so tests can't pin the window or the factor.
function zoomOut(value: TimeRange): TimeRange {
const now = new Date();
return widen(value, ZOOM_FACTOR, now);
}
// Good — constants are overridable defaults; pass `now` to make it pure.
function zoomOut({
value,
now = new Date(),
zoomFactor = DEFAULT_ZOOM_FACTOR,
minWindowMs = DEFAULT_MIN_WINDOW_MS,
}: {
value: TimeRange;
/** Reference "now". Defaults to the current time. */
now?: Date;
/** Multiplier applied to the window duration. */
zoomFactor?: number;
/** Smallest window the zoom will produce. */
minWindowMs?: number;
}): TimeRange {
return widen(value, zoomFactor, now, minWindowMs);
}
/**
* Fetch spans matching the given filters.
* @param params - query parameters
* @param params.projectId - project to query
* @param params.timeRange - optional time window to restrict results
* @param params.limit - max rows to return (default 100)
*/
function fetchSpans({
projectId,
timeRange,
limit = 100,
}: {
projectId: string;
timeRange?: TimeRange;
limit?: number;
}) {
Type Safety
TypeScript's type system is most valuable when it catches bugs at compile time rather than runtime.
- Type guards must be used to narrow complex union types; edge cases where discriminants might be missing must be tested.
any must not be used; prefer unknown and narrow explicitly. If any is genuinely necessary (e.g., interfacing with an untyped external API), add a comment explaining why.
Record<K, V> used as a lookup map (where keys may be absent) must include undefined in the value type — the repo does not enable noUncheckedIndexedAccess, so missing-key lookups silently return undefined while the type says V. Use Partial<Record<K, V>> for sparse maps or Record<K, V | undefined> when the key set is known but values are nullable.
// Bad — lookup returns string at compile time, undefined at runtime
const map: Record<string, string> = {};
const value = map["missing"]; // typed as string, actually undefined
// Good — forces a null check at every access site
const map: Partial<Record<string, string>> = {};
const value = map["missing"]; // typed as string | undefined
Imports
- Import lodash utilities via path imports so bundles only carry what's used:
import debounce from "lodash/debounce", not import { debounce } from "lodash" — the barrel import defeats tree shaking.
Reuse
Existing shared utilities must be checked before writing inline helpers. Duplicated logic should be extracted to a shared module. When working in js/packages/, check sibling packages for existing utilities before adding new dependencies or reimplementing.
1---2name: phoenix-typescript3description: TypeScript conventions and patterns for any TypeScript code in the Phoenix monorepo — including js/packages/, js/app/, and any other TS directories. Use this skill whenever writing, reviewing, or modifying TypeScript code — new functions, types, exports, tests, or refactors. Also trigger when the user asks about TS patterns, naming conventions, or best practices for this project.4---5
6# Phoenix TypeScript Conventions
7
8These conventions apply to **all** TypeScript in the Phoenix monorepo — the `js/app/` frontend, the `js/packages/` libraries (phoenix-client, phoenix-cli, phoenix-evals, phoenix-mcp, phoenix-otel, phoenix-config), examples, and benchmarks.
9
10Before writing new code, explore the directory you're working in to understand existing patterns — then follow these rules.
11
12## Naming
13
14Self-documenting names eliminate mental parsing for the next reader.
15
16- Variables must not use single letters — even loop counters benefit from `index`, `row`, `char`.
17- Complex conditions should be extracted into named booleans so code reads as prose.
18- Booleans must use verb prefixes: `isAllowed`, `hasError`, `canSubmit` — not `allowed`, `error`.
19- Function names must start with an action verb that describes what the function does: `getUser`, `normalizeTimestamp`, `logEvent`, `parseResponse`, `buildQuery` — not `user()`, `timestamp()`, `event()`.
20
21```ts
22// Bad — single letters and ambiguous names
23for (let i = 0; i < s.length; i++) {
24 const d = s[i].ts - s[i - 1]?.ts;
25 const r = fn(s[i].v);
26}
27
28// Good — self-documenting
29for (let index = 0; index < spans.length; index++) {
30 const elapsed = spans[index].timestamp - spans[index - 1]?.timestamp;
31 const result = normalizeValue(spans[index].value);
32}
33
34// Bad — boolean without verb prefix, condition inline
35<Button isDisabled={!permission || submitting}>
36
37// Good — named boolean with verb prefix
38const isDisabled = !hasPermission || isSubmitting;
39<Button isDisabled={isDisabled}>
40```
41
42## Functions
43
44- Functions with 2+ parameters should use object destructuring over positional args — this makes call sites readable and resilient to reordering.
45- Object parameters should be documented with JSDoc using `@param` dot notation so editors surface descriptions on hover and during autocomplete.
46- Behavior should be built from composition (functions and hooks), not inheritance.
47- Transforms should prefer functional purity over mutation — use `map` not `reduce` for element-wise transforms, return new objects instead of mutating.
48- Pure utilities must not reach for hardcoded module constants or ambient state (`Date.now()`, `new Date()`, config singletons) deep inside their bodies. Expose every such value as an optional parameter whose default is the constant, so callers — and especially tests — can override it and the function's output depends only on its inputs. The module constant becomes the default, prefixed `DEFAULT_`.
49
50```ts
51const DEFAULT_ZOOM_FACTOR = 2;
52const DEFAULT_MIN_WINDOW_MS = 60_000;
53
54// Bad — reads a module constant and the clock internally; output isn't
55// determined by inputs, so tests can't pin the window or the factor.
56function zoomOut(value: TimeRange): TimeRange {
57 const now = new Date();
58 return widen(value, ZOOM_FACTOR, now);
59}
60
61// Good — constants are overridable defaults; pass `now` to make it pure.
62function zoomOut({
63 value,
64 now = new Date(),
65 zoomFactor = DEFAULT_ZOOM_FACTOR,
66 minWindowMs = DEFAULT_MIN_WINDOW_MS,
67}: {
68 value: TimeRange;
69 /** Reference "now". Defaults to the current time. */
70 now?: Date;
71 /** Multiplier applied to the window duration. */
72 zoomFactor?: number;
73 /** Smallest window the zoom will produce. */
74 minWindowMs?: number;
75}): TimeRange {
76 return widen(value, zoomFactor, now, minWindowMs);
77}
78```
79
80```ts
81/**
82 * Fetch spans matching the given filters.
83 * @param params - query parameters
84 * @param params.projectId - project to query
85 * @param params.timeRange - optional time window to restrict results
86 * @param params.limit - max rows to return (default 100)
87 */
88function fetchSpans({
89 projectId,
90 timeRange,
91 limit = 100,
92}: {
93 projectId: string;
94 timeRange?: TimeRange;
95 limit?: number;
96}) {
97```
98
99## Type Safety
100
101TypeScript's type system is most valuable when it catches bugs at compile time rather than runtime.
102
103- Type guards must be used to narrow complex union types; edge cases where discriminants might be missing must be tested.
104- `any` must not be used; prefer `unknown` and narrow explicitly. If `any` is genuinely necessary (e.g., interfacing with an untyped external API), add a comment explaining why.
105- `Record<K, V>` used as a lookup map (where keys may be absent) must include `undefined` in the value type — the repo does not enable `noUncheckedIndexedAccess`, so missing-key lookups silently return `undefined` while the type says `V`. Use `Partial<Record<K, V>>` for sparse maps or `Record<K, V | undefined>` when the key set is known but values are nullable.
106
107```ts
108// Bad — lookup returns string at compile time, undefined at runtime
109const map: Record<string, string> = {};
110const value = map["missing"]; // typed as string, actually undefined
111
112// Good — forces a null check at every access site
113const map: Partial<Record<string, string>> = {};
114const value = map["missing"]; // typed as string | undefined
115```
116
117## Imports
118
119- Import lodash utilities via path imports so bundles only carry what's used: `import debounce from "lodash/debounce"`, not `import { debounce } from "lodash"` — the barrel import defeats tree shaking.
120
121## Reuse
122
123Existing shared utilities must be checked before writing inline helpers. Duplicated logic should be extracted to a shared module. When working in `js/packages/`, check sibling packages for existing utilities before adding new dependencies or reimplementing.