mcpField
T
typescript-best-practices

typescript-best-practices

github.com/overextendedDevelopment208 installsUpdated 2w ago
Repository

About typescript-best-practices

A TypeScript guidance skill for FiveM resource developers. It focuses on modeling valid states and validating untyped runtime boundaries so business logic can rely on stronger types.

Key features

  1. 01Model variants with discriminated unions
  2. 02Use branded types for distinct identifiers
  3. 03Validate untyped data at boundaries
  4. 04Prefer unknown and verified type guards
  5. 05Test plain logic and log useful context

Use cases

  1. 01Typing FiveM network event inputs
  2. 02Validating NUI and persisted data
  3. 03Modeling mutually exclusive states
  4. 04Reducing unsafe casts in resource code

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/overextended/skills --skill typescript-best-practices

After installing, type/typescript-best-practicesin Claude Code to confirm it loaded.

Bundled files

References and scripts installed along with SKILL.md.

CREDITS.md
PATTERNS.md
agents/
openai.yaml

Details

From the project README

TypeScript best practices

Make the compiler reject impossible states, mixed-up ids and unhandled variants. In a FiveM resource most of the type safety leaks out at the runtime's edges: onNet, exports, state bags and NUI callbacks are typed any or Function, so a type annotation there is a cast that nothing checks. Parse at those edges and trust the types everywhere else.

Principles

  • Make illegal states unrepresentable. If you can write a comment explaining when a combination of fields is valid, split it into a discriminated union.
  • Build types, don't restrict them. Choose the shape that can't hold the illegal value (a head plus a rest, a start plus a duration) instead of a loose type plus runtime checks.
  • Don't lie to the compiler. If it can't prove a fact, prove it (validate, narrow, remodel). Trace every any, as and ! to the boundary and validate there.
  • Strengthen a type only where partiality appears. A !, a cast or a "should never happen" throw marks a type that is too weak. Push that check into the type, then stop.
  • Validate at boundaries, trust inside. Business logic lives in plain functions that take typed data. The event handler around it is thin: parse, call, reply.

Rules

RuleSummary
Discriminated unionsModel variants with a kind literal discriminant so impossible states can't be represented. No optional-field bags.
Branded typesBrand primitives with & { readonly __brand: "X" } so a server id, a character id and an item name can't be mixed up. Validate once at the boundary.
Constructive modelingBuild the shape so the illegal value can't be constructed. [T, ...T[]] for non-empty, [T, T][] for even length, start plus duration for a range.
Simplest total typeKeep T[] while every operation on it stays total. Strengthen to NonEmpty<T> only where the loose type forces !, a cast, or a "should never happen" throw.
unknown over anyData from outside the resource is unknown, including the arguments of net event handlers.
Parse functionsTurn client data into a typed value with a function that takes unknown and returns the value or null (toCoords(value: unknown): Vec3 | null). Reuse the resource's existing parsers and guards before writing a new one. Don't add a schema library: FiveM resources don't use one.
null means omittedFiveM serializes event arguments with MessagePack, which can turn an omitted or undefined value into null. Check optional values with != null, never !== undefined alone, and require values by type and range, not truthiness: 0 and false are valid.
No as castsEvery as is a runtime crash waiting. Cast only after validation. A type argument such as triggerServerCallback<T> is a cast too.
Narrowing hierarchyDiscriminant switch > in operator > typeof/instanceof > user-defined type guard > as.
Type guardsMust verify the claim. A lying guard is worse than as because the bug hides behind a name that says it's safe. Name them isX or hasX.
ExhaustivenessInline const _exhaustive: never = x; in default arms so the compiler errors when a new variant is added.
satisfies over asValidates the value without widening literal types.
Boundary validationParse where data crosses in, into a named domain type. Trust types inside. See FiveM boundaries below.
Derived typesReach for Pick/Omit/Parameters/ReturnType/Awaited/typeof before declaring a new interface. Share one type between the client script and the NUI instead of declaring it twice.
Object argsPass objects, not positional, so argument order is self-documenting. Skip on hot paths (setTick callbacks, anything running every frame) and don't wrap natives just to get object args.
Real testsDon't mock what you can run. Plain logic gets real unit tests when the resource has a runner; natives, events and NUI are verified on a running server.
Useful logsLog with enough context to debug from an id: player id, event name, resource. No debug console.log left in shipped code.

FiveM boundaries

Every one of these is data the compiler knows nothing about. Parse it where it arrives:

BoundaryWhy it's untyped
onNet handler arguments on the serverAny client can trigger the event with any arguments. onNet takes a Function, so parameter annotations go unchecked.
onClientCallback arguments (@overextended/ox_lib/server)Typed ...args: any[], and they come from a client.
RegisterNuiCallback dataComes from the browser, which the player can drive with devtools. The server re-checks anything the client forwards to it.
exports.<resource>.<name>() resultsEvery export is typed Function, so its result is any. Wrap it once in a typed function.
State bags (GlobalState, Player(id).state, Entity(id).state)Indexed as any. Treat a key a client can write as client data.
triggerServerCallback<T> replies (@overextended/ox_lib/client)T is not checked, and the return type is Promise<T> | void: it resolves to nothing when its delay rate limit drops the call.
JSON.parse, KVP strings, convars, database rows, item metadataStrings or untyped rows. Persisted JSON gets a version field and a try / catch around the parse.

On the server, copy source into a local at the top of every handler, before any await: it is a global that the next event overwrites.

When the resource uses ox_lib from TypeScript, import @overextended/ox_lib instead of calling exports.ox_lib directly: the package wraps the exports with types.

Examples

Read PATTERNS.md for a code example of each rule.

Comments

Markdown supported