| description | TypeScript conventions — type safety, functional primitives, domain modeling. |
|---|---|
| globs | *.ts, *.tsx |
| alwaysApply | false |
Favour static types, explicit data flow, immutability, pure functions, composition, exhaustive matching, monadic error handling (Result/Maybe/Future), strict generics, and branded values over raw primitives.
- Use branded classes for entity IDs. Don't use
string & { __brand }intersections — they allow name collisions, leak__brandinto intellisense, and accept raw strings without constructors.class MyId { // @ts-expect-error _tag's existence prevents structural comparison private readonly _tag: null = null; constructor(public value: string) {} }
- Use discriminated unions to make invalid states unrepresentable. Don't use bags of optional properties when combinations create impossible states.
type State = { status: "loading" } | { status: "error"; error: Error } | { status: "success"; data: { id: string } };
- Use exhaustive
switchwith aneverdefault on discriminated unions. This forces handling new variants at compile time.default: { const _exhaustiveCheck: never = config; throw new Error(`Unknown: ${JSON.stringify(_exhaustiveCheck)}`); }
- Don't use empty objects (e.g.
ConversationId.empty()) to represent absence. UseMaybe<T>withNothing()instead. - Use
as constobjects instead ofenum. Derive the type withtype X = (typeof X)[keyof typeof X].const PackStatus = { Draft: "Draft", Approved: "Approved", Shipped: "Shipped" } as const; type PackStatus = (typeof PackStatus)[keyof typeof PackStatus];
- Declare return types on top-level module functions. Exception: JSX components returning JSX.
- Avoid
any. Use strict generics to preserve type information:function parse<T>(data: { result: T }): T { return data.result; }
-
Co-locate
static schemaon entity ID classes for native serialization/deserialization.class MessageId { static schema = idSchema(this); readonly _tag: "MessageId"; }
-
Model rich content (LLM outputs, conversation events) with
s.discriminatedUnion+s.variant. Don't use giant bags of optional properties.const schema_AgentExecutionTrace = s.discriminatedUnion([ s.variant({ type: "text", text: s.string }), s.variant({ type: "tool_call", name: s.string, input: s.json, result: schema_Result(schema_Error, s.json) }), s.variant({ type: "error", message: s.string, code: s.optional(s.string) }) ]);
-
Bundle related state into union-driven state machines. Don't use loose boolean flags (
isStreaming,isError,isLoading) spread across stores.type Stream<E, R> = { type: "not_started" } | { type: "streaming"; results: R[] } | { type: "done"; results: R[] } | { type: "error"; error: E }; type VoiceConnection = | { type: "disconnected" } | { type: "connecting" } | { type: "transcribing"; transcription: string } | { type: "error"; error: FetchErrorResponse }; type UserInput = { type: "text"; content: string } | { type: "voice"; connection: VoiceConnection }; interface ActiveConversation { id: ConversationId; messages: Array<Message>; inputMode: UserInput; streamingResponse: Stream<Error, string>; }
Use Maybe<T> instead of null/undefined. Construct with Just(value) or Nothing().
- Pattern match with
instanceof Just/instanceof Nothing+satisfies neverin default. Don't useisJust()/isNothing()— they don't narrow types.switch (true) { case maybeUser instanceof Just: console.log(maybeUser.value); break; case maybeUser instanceof Nothing: console.log("No user"); break; default: maybeUser satisfies never; }
- Use
.map(fn)for transforms. Use.chain(fn)(flatMap) whenfnreturnsMaybe<T>— avoidsMaybe<Maybe<T>>. - Use
.withDefault(fallback)or.maybe(default, fn)for default values. - Use
.alt(other)to chain fallback Maybe values:primary.alt(secondary).alt(fallback). - Use
fromNullable()fornullandfromOptional()forundefinedat system boundaries. - Use
catMaybes(arr)to filter outNothingvalues,mapMaybe(arr, fn)to map+filter in one pass. - Don't use
.expect()for recoverable absence — it throws. Use.withDefault()or.maybe(). - Don't mix
fromNullableandfromOptional— they handle different nullish types.
Use Result<E, T> for fallible operations. Return Failure(error) instead of throwing. Never throw unless it's a catastrophic programmer bug.
- Construct with
Success<E, T>(value)orFailure<E, T>(error)— callable withoutnew. - Use
.either(onError, onSuccess)for exhaustive fold.const handle = (result: Result<Error, User>): string => result.either( (e) => e.message, (user) => user.name );
- Use
.chain(fn)for monadic sequencing — short-circuits on firstFailure.parseJson(input).chain(validate).chain(transform);
- Use
.map(fn)for pure transforms onSuccess,.mapFailure(fn)to transform error types. - Don't mix
Resultwithtry/catch. Don't use.unwrap()outside boundaries — it throws onFailure. traverseworks withList,traverse_works withArray. Both short-circuit on firstFailure.
Use RemoteData<E, T> to model async UI state. Don't use { loading: bool; error: Error | null; data: T | null } — it allows impossible combinations.
- States:
NotAsked(),Loading(),Failed(error),Ready(value). - Pattern match with
instanceof+satisfies never.switch (true) { case state instanceof Ready: render(state.value); break; case state instanceof Failed: showError(state.error); break; case state instanceof Loading: showSpinner(); break; case state instanceof NotAsked: break; default: state satisfies never; }
.map(fn)transforms onlyReady; preservesLoading/Failed/NotAsked..chain(fn)forRemoteData-returning functions — avoids double wrapping.NotAskedmeans "haven't asked yet". For "asked but empty", useReady([]).- Don't check
isReadywithoutinstanceof— boolean flags don't narrow types.
Use Future<E, T> instead of Promise for lazy, cancelable async.
- Create with
Future.create<E, T>((reject, resolve) => { ... return cancelFn }). Return the cancel function.const future = Future.create<never, number>((reject, resolve) => { const timer = setTimeout(() => resolve(42), 1000); return () => clearTimeout(timer); });
- Use
Future.createUncancellablefor inherently uncancellable operations. - Nothing executes until
.fork(onError, onSuccess)is called.forkreturns a cancel function — store it if cancellation is needed. - Don't use
Future.attemptPfor cancellable operations — it loses cancellation semantics. Use it only for wrapping simple Promises:Future.attemptP(() => someAsyncFn()). - Don't double-wrap Promises:
Future.attemptP(() => fn()), notFuture.attemptP(async () => { const r = await fn(); return r; }). - Use
.chain(fn)for sequential async composition.fetchUser(id) .chain((user) => fetchPosts(user.id).map((posts) => ({ user, posts }))) .fork(handleError, ({ user, posts }) => render(user, posts));
- Use
Future.parallel(limit, futures)for bounded concurrency. UseFuture.concurrently({...})for named concurrent operations. - Use
.chainRej(fn)to recover from errors..mapRej(fn)transforms errors but stays rejected. - Use
Future.bracket(acquire, release, use)for guaranteed resource cleanup (locks, connections, file descriptors). - Use
Future.race(a, b)for timeouts. - Convert to Promise with
await future.promise(e => new Error(String(e.message))). attemptPalways producesFuture<Error, T>— use.mapRej()to narrow the error type.
- Use
List<T>for O(1) prepend and immutable functional sequences. - Don't append onto linked lists — O(n²). Build with
List.cons(item, list)+.reverse()at the end, orList.from(arr). .head()returnsMaybe<T>— always handleNothing.
- Use
TreeMap/TreeSetwith explicit comparators for ordered, persistent collections. Replace unordered JSMap/Setwhen iteration order matters.const map = TreeMap.new<string, number>((x, y) => x > y ? 1 : x < y ? -1 : 0 ); // Or use stringMap factory / Comparable interface const map = stringMap<User>(); const map = TreeMap.new_<UserId, User>();
.get(key)returnsMaybe<T>— always handleNothing.- Use
.unionWith(other, mergeFn)for merging with conflict resolution. - Use
.difference(other)and.intersectionWith(other, fn)for set operations. - Comparator must return
-1 | 0 | 1. Boolean won't work. TreeMapis sorted by comparator, not insertion order.TreeSet:.insert(),.remove(),.union()mutate in place. UseTreeSet.from()to clone first.- Use
.has()for O(log n) membership. Don't use.values().includes()— that's O(n).
Use MVar<T> to coordinate concurrent operations through a single mutable cell. Operations are FIFO — fair across waiters, no starvation.
- Construct with
MVar.new(v)(full) orMVar.newEmpty()(empty). put/take/read/modifyblock;tryPut/tryTake/tryReaddon't.tryPutreturnsboolean;tryTake/tryReadreturnMaybe<A>— pattern match withinstanceof Just/instanceof Nothing.- Use
modify(not external locks) to atomically transform shared state — the original value is restored if the callback rejects. - Don't busy-loop on
tryTake— usetaketo block. - Don't use boolean flags or unbounded arrays for "done" state — use
MVarto block until populated.
Completion/error latch with MVar<Maybe<Error>> — surface either "closed cleanly" or "closed with error" from a callback-based lifecycle.
const done: MVar<Maybe<Error>> = MVar.newEmpty();
ws.on("close", () => {
done.tryPut(Nothing());
});
ws.on("error", (err) => {
done.tryPut(Just(err));
});
const result = await done.take();
if (result instanceof Just) throw result.value;End-of-stream latch with MVar<null> — convert a callback-based "done" signal into a value awaitable from an AsyncIterable.
const end = MVar.newEmpty<null>();
const finished = end.take();
const iterable: AsyncIterable<string> = {
[Symbol.asyncIterator]() {
return {
next: () => Promise.race([buffer.dequeue().then((value) => ({ done: false, value })), finished.then(() => ({ done: true, value: undefined }))])
};
}
};
model.onDone(() => {
end.put(null);
});Atomic state via modify — guard mutable shared state with automatic rollback on rejection.
const counter = MVar.new(0);
const previous = await counter.modify(async (n) => [n + 1, n]);- Use
Queue<T>for immutable persistent queues —enqueuereturns a newQueue; safe to share across async boundaries without copying. - Use
MQueue<T>for transient producer-consumer queues where mutation is local (e.g. waiter queues inside coordination primitives). Queue.dequeue()returnsMaybe<[T, Queue<T>]>;MQueue.dequeue()returnsMaybe<T>— both must be pattern-matched.- Both have amortised O(1) enqueue/dequeue via two-list (front/back) representation.
- Don't reach for native
Array.shift()for FIFO — it's O(n) and mutates.
let q = Queue.fromArray([1, 2, 3]);
const r = q.dequeue();
if (r instanceof Just) {
const [head, rest] = r.value;
q = rest; // thread the new queue
}- Never cast
JSON.parse(x) as T. Validate with a decoder returningResult<string, T>.const result = Decoder.decode(JSON.parse(input), Decoder.string);
- Build object decoders with
Decoder.object({ ... }). - Use
Decoder.optional()for fields that may not exist (V | undefined). - Use
Decoder.nullable()for fields where value may benull(V | null). - Use
Decoder.optionalNullable()for fields that may be absent OR null. - Use
Decoder.optionalMaybe()for missing →Maybe<V>. - Use
Decoder.oneOf()+Decoder.stringLiteral()for discriminated JSON unions. - Always derive types from decoders:
type User = Decoder.Infer<typeof userDecoder>. Don't cast withasafter decode. - Use
.chain()for version-dependent decoding. - Use
Decoder.objectMap()for{ [key: string]: T }shapes. Don't useDecoder.object()for dynamic keys.
- Use
E.object<T>({...})for structured serialization. - Use
E.optional(encoder)to omit fields whenundefined. - Transform inputs with
.rmap(fn)(contravariant — transforms input before encoding).const dateEncoder = E.string.rmap((d: Date) => d.toISOString()); const userIdEncoder = E.string.rmap((id: UserId) => id.value);
- Use
E.oneOf<T>(selector)for dynamic encoder selection. - Use
E.both(enc1, enc2)to merge encoder outputs. - Must call
.run(value)to execute —Encoder<A>is a description, not a result. - Don't use
E.maybe()for optional fields — it produces{ just: V }structure. UseE.optional(). E.EncoderOptionalonly works withinE.object()field definitions.
- A
Schemais a combinedDecoder+Encoder. Build withs.string.dimap(decode, encode). - Keep schemas as
static schemaon domain classes to prevent serialization drift.class MessageId { private readonly _tag: null = null; constructor(public value: string) {} static schema = s.string.dimap( (v) => new MessageId(v), (id) => id.value ); }
- Use
s.discriminatedUnion+s.variantfor sum types. Always useas conston variant discriminants.const Message = s.discriminatedUnion([ s.variant({ type: "error" as const, code: s.number, message: s.string }), s.variant({ type: "success" as const, value: s.string }) ]); type Message = s.Infer<typeof Message>;
- Use
s.optional()for missing keys. Uses.nullable()for present-but-null values. Don't combine intos.optional(s.maybe(x))— createsMaybe<Maybe<T>>.