Skip to content

Commit 44fdd94

Browse files
jenn-lenoencke
andauthored
feat(tree): add api for providing defaults on required and optional fields (#26502)
Adds the `withDefault` API to `SchemaFactoryAlpha`. It allows you to specify default values for fields, making them optional in constructors even when the field is marked as required in the schema. Defaults can be provided as either values (primitives or nodes) or functions that generate the default value. Also adds `withDefaultRecursive` which supports specifying default values within recursive types. --------- Co-authored-by: Noah Encke <78610362+noencke@users.noreply.github.com>
1 parent dc2cc8c commit 44fdd94

File tree

17 files changed

+1878
-77
lines changed

17 files changed

+1878
-77
lines changed

.changeset/spicy-meals-itch.md

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
---
2+
"fluid-framework": minor
3+
"@fluidframework/tree": minor
4+
"__section": feature
5+
---
6+
Adds withDefault API to allow defining default values for required and optional fields
7+
8+
The `withDefault` API is now available on `SchemaFactoryAlpha`. It allows you to specify default values for fields,
9+
making them optional in constructors even when the field is marked as required in the schema.
10+
This provides a better developer experience by reducing boilerplate when creating objects.
11+
12+
The `withDefault` API wraps a field schema and defines a default value to use when the field is not provided during
13+
construction. The default value must be of an allowed type of the field. You can provide defaults in two ways:
14+
15+
- **A value**: When a value is provided directly, the data is copied for each use to ensure independence between instances
16+
- **A generator function**: A function that is called each time to produce a fresh value
17+
18+
Defaults are evaluated eagerly during node construction.
19+
20+
#### Required fields with defaults
21+
22+
```typescript
23+
import { SchemaFactoryAlpha, TreeAlpha } from "@fluidframework/tree/alpha";
24+
25+
const sf = new SchemaFactoryAlpha("example");
26+
27+
class Person extends sf.objectAlpha("Person", {
28+
name: sf.required(sf.string),
29+
age: sf.withDefault(sf.required(sf.number), -1),
30+
role: sf.withDefault(sf.required(sf.string), "guest"),
31+
}) {}
32+
33+
// Before: all fields were required
34+
// const person = new Person({ name: "Alice", age: -1, role: "guest" });
35+
36+
// After: fields with defaults are optional
37+
const person = new Person({ name: "Alice" });
38+
// person.age === -1
39+
// person.role === "guest"
40+
41+
// You can still provide values to override the defaults
42+
const admin = new Person({ name: "Bob", age: 30, role: "admin" });
43+
```
44+
45+
#### Optional fields with custom defaults
46+
47+
Optional fields (`sf.optional`) already default to `undefined`, but `withDefault` allows you to specify a different
48+
default value:
49+
50+
```typescript
51+
class Config extends sf.object("Config", {
52+
timeout: sf.withDefault(sf.optional(sf.number), 5000),
53+
retries: sf.withDefault(sf.optional(sf.number), 3),
54+
}) {}
55+
56+
// All fields are optional, using custom defaults when not provided
57+
const config = new Config({});
58+
// config.timeout === 5000
59+
// config.retries === 3
60+
61+
const customConfig = new Config({ timeout: 10000 });
62+
// customConfig.timeout === 10000
63+
// customConfig.retries === 3
64+
```
65+
66+
#### Value defaults vs function defaults
67+
68+
When you provide a value directly, the data is copied for each use, ensuring each instance is independent:
69+
70+
```typescript
71+
class Metadata extends sf.object("Metadata", {
72+
tags: sf.array(sf.string),
73+
version: sf.number,
74+
}) {}
75+
76+
class Article extends sf.object("Article", {
77+
title: sf.required(sf.string),
78+
79+
// a node is provided directly, it is copied for each use
80+
metadata: sf.withDefault(sf.optional(Metadata), new Metadata({ tags: [], version: 1 })),
81+
82+
// also works with arrays
83+
authors: sf.withDefault(sf.optional(sf.array(sf.string)), []),
84+
}) {}
85+
86+
const article1 = new Article({ title: "First" });
87+
const article2 = new Article({ title: "Second" });
88+
89+
// each article gets its own independent copy
90+
assert(article1.metadata !== article2.metadata);
91+
article1.metadata.version = 2; // Doesn't affect article2
92+
assert(article2.metadata.version === 1);
93+
```
94+
95+
Alternatively, you can use generator functions to explicitly create new instances:
96+
97+
```typescript
98+
class Article extends sf.object("Article", {
99+
title: sf.required(sf.string),
100+
101+
// generators are called each time to create a new instance
102+
metadata: sf.withDefault(sf.optional(Metadata), () => new Metadata({ tags: [], version: 1 })),
103+
authors: sf.withDefault(sf.optional(sf.array(sf.string)), () => []),
104+
}) {}
105+
```
106+
107+
Insertable object literals, arrays, and map objects can be used in place of node instances in both static defaults
108+
and generator functions:
109+
110+
```typescript
111+
class Article extends sf.object("Article", {
112+
title: sf.required(sf.string),
113+
114+
// plain object literal instead of new Metadata(...)
115+
metadata: sf.withDefault(sf.optional(Metadata), () => ({ tags: [], version: 1 })),
116+
117+
// plain array instead of new ArrayNode(...)
118+
authors: sf.withDefault(sf.optional(sf.array(sf.string)), () => ["anonymous"]),
119+
}) {}
120+
```
121+
122+
##### Dynamic defaults
123+
124+
Generator functions are called each time a new node is created, enabling dynamic defaults:
125+
126+
```typescript
127+
class Document extends sf.object("Document", {
128+
id: sf.withDefault(sf.required(sf.string), () => crypto.randomUUID()),
129+
title: sf.required(sf.string),
130+
}) {}
131+
132+
const doc1 = new Document({ title: "First Document" });
133+
const doc2 = new Document({ title: "Second Document" });
134+
// doc1.id !== doc2.id (each gets a unique UUID)
135+
```
136+
137+
Generator functions also work with primitive types:
138+
139+
```typescript
140+
let counter = 0;
141+
142+
class GameState extends sf.object("GameState", {
143+
playerId: sf.withDefault(sf.required(sf.string), () => `player-${counter++}`),
144+
score: sf.withDefault(sf.required(sf.number), () => Math.floor(Math.random() * 100)),
145+
isActive: sf.withDefault(sf.required(sf.boolean), () => counter % 2 === 0),
146+
}) {}
147+
```
148+
149+
#### Recursive types
150+
151+
`withDefaultRecursive` is available for use inside recursive schemas. Use `objectRecursiveAlpha` (rather than
152+
`objectRecursive`) when defining recursive schemas with defaults, as it correctly makes defaulted fields optional in
153+
the constructor for all field kinds including `requiredRecursive`. It works the same as `withDefault` but is
154+
necessary to avoid TypeScript's circular reference limitations.
155+
156+
```typescript
157+
class TreeNode extends sf.objectRecursiveAlpha("TreeNode", {
158+
value: sf.number,
159+
label: SchemaFactoryAlpha.withDefaultRecursive(sf.optional(sf.string), "untitled"),
160+
child: sf.optionalRecursive([() => TreeNode]),
161+
}) {}
162+
163+
// `label` is optional in the constructor — the default is used when omitted
164+
const leaf = new TreeNode({ value: 1 });
165+
// leaf.label === "untitled"
166+
167+
const root = new TreeNode({ value: 0, label: "root", child: leaf });
168+
// root.label === "root"
169+
// root.child.label === "untitled"
170+
```
171+
172+
> **Warning:** Be careful about using the recursive type itself as a default value — this is likely to cause
173+
> infinite recursion at construction time, since creating the default value would trigger the same default again.
174+
> Instead, use a primitive or a separate node type as the default:
175+
>
176+
> ```typescript
177+
> const DefaultTag = sf.objectRecursiveAlpha("Tag", { id: sf.number, child: sf.optionalRecursive([() => TreeNode]) });
178+
>
179+
> class TreeNode extends sf.objectRecursiveAlpha("TreeNode", {
180+
> value: sf.number,
181+
> // ✅ Safe: default is a non-recursive node
182+
> tag: SchemaFactoryAlpha.withDefaultRecursive(sf.optional(DefaultTag), () => new DefaultTag({ id: 0, child: new DefaultTag({ id: 1 }) })),
183+
> child: sf.optionalRecursive([() => TreeNode]),
184+
> }) {}
185+
> ```
186+
>
187+
> The following definition for child would cause infinite recursion at construction time:
188+
>
189+
> ```typescript
190+
> child: SchemaFactoryAlpha.withDefaultRecursive(sf.optionalRecursive([() => TreeNode]), () => new TreeNode({ value: 0 }))
191+
> ```
192+
193+
> The infinite recursion can be solved by passing in undefined explicitly but it is
194+
> recommended to not use defaults in this case:
195+
>
196+
> ```typescript
197+
> child: SchemaFactoryAlpha.withDefaultRecursive(sf.optionalRecursive([() => TreeNode]), () => new TreeNode({ value: 0, child: undefined }))
198+
> ```
199+
200+
#### Type safety
201+
202+
The default value (or the value returned by a generator function) must be of an allowed type for the field. TypeScript
203+
enforces this at compile time:
204+
205+
```typescript
206+
// ✅ Valid: number default for number field
207+
sf.withDefault(sf.optional(sf.number), 8080);
208+
209+
// ✅ Valid: generator returns string for string field
210+
sf.withDefault(sf.optional(sf.string), () => "localhost");
211+
212+
// ❌ TypeScript error: string default for number field
213+
sf.withDefault(sf.optional(sf.number), "8080");
214+
215+
// ❌ TypeScript error: generator returns number for string field
216+
sf.withDefault(sf.optional(sf.string), () => 8080);
217+
```

0 commit comments

Comments
 (0)