Skip to content

Commit 162796d

Browse files
committed
Add generic type narrowing to primitive field factories; v5.0.5
`t.int8<-1 | 0 | 1>()` / `t.string<"red" | "blue">()` etc. refine the inferred field type while the wire codec is unchanged. Implemented as an overloaded `PrimitiveFactory<TBase>` (bare call → concrete `FieldBuilder<TBase>`; explicit type arg → `FieldBuilder<T>`) rather than a defaulted generic, which would be captured as `any` during `schema()`'s self-referential inference. Type-level assertion only — the decoder still writes raw bytes, so input schemas must keep validating on receipt. Assisted-by: Claude Opus 4.7
1 parent d34411e commit 162796d

3 files changed

Lines changed: 55 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,33 @@ All notable changes to this project are documented in this file. The
44
format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/).
66

7+
## [5.0.5]
8+
9+
### Added
10+
- Generic type narrowing on the primitive field factories. Pass an explicit
11+
type argument to `t.int8()` / `t.string()` / etc. to refine the inferred
12+
field type, while the wire encoding is unchanged:
13+
14+
```ts
15+
const MoveInput = schema({
16+
moveX: t.int8<-1 | 0 | 1>(), // typed -1 | 0 | 1, still a 1-byte int8
17+
team: t.string<"red" | "blue">(), // typed "red" | "blue", still a string
18+
});
19+
```
20+
21+
Each `t.<primitive>()` now has two call signatures: the bare call returns the
22+
natural type for the codec (`t.int8()``number`), and an explicit type
23+
argument returns `FieldBuilder<T>`. This is an overload pair rather than a
24+
defaulted generic (`<T extends TBase = TBase>()`): a defaulted free type
25+
parameter gets captured as `any` during `schema()`'s self-referential field
26+
inference (and `undefined extends any` then flips every field optional), so
27+
the bare form must stay a concrete `FieldBuilder<TBase>`.
28+
29+
The refinement is a **type-level assertion only** — the wire still carries the
30+
codec's full range and the decoder writes whatever bytes arrive. Sound for
31+
server-authored state; for input schemas (untrusted client) keep validating /
32+
clamping on receipt.
33+
734
## [5.0.4]
835

936
### Added

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@colyseus/schema",
3-
"version": "5.0.4",
3+
"version": "5.0.5",
44
"description": "Binary state serializer with delta encoding for games",
55
"type": "module",
66
"bin": {

src/types/builder.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -257,8 +257,33 @@ export function isBuilder(value: any): value is FieldBuilder<any> {
257257
// Factory helpers
258258
// ---------------------------------------------------------------------------
259259

260-
function primitive<T>(name: RawPrimitiveType): () => FieldBuilder<T> {
261-
return () => new FieldBuilder<T>(name);
260+
/**
261+
* Primitive field factory. Calling it bare (`t.int8()`) yields the natural type
262+
* for the wire codec (`number` for the int/float formats, plus `string` /
263+
* `boolean` / `bigint`). Pass an explicit type argument to refine the inferred
264+
* value at the TYPE level, while the wire encoding is unchanged:
265+
*
266+
* moveX: t.int8<-1 | 0 | 1>(), // typed -1|0|1, still encoded as int8
267+
* team: t.string<"red" | "blue">(),
268+
*
269+
* Two call signatures, NOT a defaulted generic `<T extends TBase = TBase>`: the
270+
* bare form must return a CONCRETE `FieldBuilder<TBase>` so `schema({ x:
271+
* t.number() })` still infers `x: number`. A defaulted free type parameter gets
272+
* captured as `any` during `schema()`'s self-referential field inference (and
273+
* `undefined extends any` then flips every field optional).
274+
*
275+
* NOTE: the refinement is a TYPE-LEVEL assertion, not a runtime guarantee — the
276+
* wire still carries the codec's full range and the DECODER writes whatever
277+
* bytes arrive. Sound for server-authored state; for INPUT schemas the value
278+
* comes from an untrusted client (the type reads `-1|0|1` while a peer can send
279+
* any int8), so keep validating/clamping on the receiving side.
280+
*/
281+
interface PrimitiveFactory<TBase> {
282+
(): FieldBuilder<TBase>;
283+
<T extends TBase>(): FieldBuilder<T>;
284+
}
285+
function primitive<TBase>(name: RawPrimitiveType): PrimitiveFactory<TBase> {
286+
return (() => new FieldBuilder<TBase>(name)) as PrimitiveFactory<TBase>;
262287
}
263288

264289
// Accepts a Schema class, a primitive string, or another FieldBuilder as a child type.

0 commit comments

Comments
 (0)