github microsoft/FluidFramework client_v2.91.0
Fluid Framework v2.91.0 (minor)

7 hours ago

Contents

  • ✨ New Features
    • Adds withDefault API to allow defining default values for required and optional fields (#26502)

✨ New Features

Adds withDefault API to allow defining default values for required and optional fields (#26502)

The withDefault API is now available on 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. This provides a better developer experience by reducing boilerplate when creating objects.

The withDefault API wraps a field schema and defines a default value to use when the field is not provided during construction. The default value must be of an allowed type of the field. You can provide defaults in two ways:

  • A value: When a value is provided directly, the data is copied for each use to ensure independence between instances
  • A generator function: A function that is called each time to produce a fresh value

Defaults are evaluated eagerly during node construction.

Required fields with defaults

import { SchemaFactoryAlpha, TreeAlpha } from "@fluidframework/tree/alpha";

const sf = new SchemaFactoryAlpha("example");

class Person extends sf.objectAlpha("Person", {
  name: sf.required(sf.string),
  age: sf.withDefault(sf.required(sf.number), -1),
  role: sf.withDefault(sf.required(sf.string), "guest"),
}) {}

// Before: all fields were required
// const person = new Person({ name: "Alice", age: -1, role: "guest" });

// After: fields with defaults are optional
const person = new Person({ name: "Alice" });
// person.age === -1
// person.role === "guest"

// You can still provide values to override the defaults
const admin = new Person({ name: "Bob", age: 30, role: "admin" });

Optional fields with custom defaults

Optional fields (sf.optional) already default to undefined, but withDefault allows you to specify a different default value:

class Config extends sf.object("Config", {
  timeout: sf.withDefault(sf.optional(sf.number), 5000),
  retries: sf.withDefault(sf.optional(sf.number), 3),
}) {}

// All fields are optional, using custom defaults when not provided
const config = new Config({});
// config.timeout === 5000
// config.retries === 3

const customConfig = new Config({ timeout: 10000 });
// customConfig.timeout === 10000
// customConfig.retries === 3

Value defaults vs function defaults

When you provide a value directly, the data is copied for each use, ensuring each instance is independent:

class Metadata extends sf.object("Metadata", {
  tags: sf.array(sf.string),
  version: sf.number,
}) {}

class Article extends sf.object("Article", {
  title: sf.required(sf.string),

  // a node is provided directly, it is copied for each use
  metadata: sf.withDefault(
    sf.optional(Metadata),
    new Metadata({ tags: [], version: 1 }),
  ),

  // also works with arrays
  authors: sf.withDefault(sf.optional(sf.array(sf.string)), []),
}) {}

const article1 = new Article({ title: "First" });
const article2 = new Article({ title: "Second" });

// each article gets its own independent copy
assert(article1.metadata !== article2.metadata);
article1.metadata.version = 2; // Doesn't affect article2
assert(article2.metadata.version === 1);

Alternatively, you can use generator functions to explicitly create new instances:

class Article extends sf.object("Article", {
  title: sf.required(sf.string),

  // generators are called each time to create a new instance
  metadata: sf.withDefault(
    sf.optional(Metadata),
    () => new Metadata({ tags: [], version: 1 }),
  ),
  authors: sf.withDefault(sf.optional(sf.array(sf.string)), () => []),
}) {}

Insertable object literals, arrays, and map objects can be used in place of node instances in both static defaults and generator functions:

class Article extends sf.object("Article", {
  title: sf.required(sf.string),

  // plain object literal instead of new Metadata(...)
  metadata: sf.withDefault(sf.optional(Metadata), () => ({
    tags: [],
    version: 1,
  })),

  // plain array instead of new ArrayNode(...)
  authors: sf.withDefault(sf.optional(sf.array(sf.string)), () => [
    "anonymous",
  ]),
}) {}
Dynamic defaults

Generator functions are called each time a new node is created, enabling dynamic defaults:

class Document extends sf.object("Document", {
  id: sf.withDefault(sf.required(sf.string), () => crypto.randomUUID()),
  title: sf.required(sf.string),
}) {}

const doc1 = new Document({ title: "First Document" });
const doc2 = new Document({ title: "Second Document" });
// doc1.id !== doc2.id (each gets a unique UUID)

Generator functions also work with primitive types:

let counter = 0;

class GameState extends sf.object("GameState", {
  playerId: sf.withDefault(sf.required(sf.string), () => `player-${counter++}`),
  score: sf.withDefault(sf.required(sf.number), () =>
    Math.floor(Math.random() * 100),
  ),
  isActive: sf.withDefault(sf.required(sf.boolean), () => counter % 2 === 0),
}) {}

Recursive types

withDefaultRecursive is available for use inside recursive schemas. Use objectRecursiveAlpha (rather than objectRecursive) when defining recursive schemas with defaults, as it correctly makes defaulted fields optional in the constructor for all field kinds including requiredRecursive. It works the same as withDefault but is necessary to avoid TypeScript's circular reference limitations.

class TreeNode extends sf.objectRecursiveAlpha("TreeNode", {
  value: sf.number,
  label: SchemaFactoryAlpha.withDefaultRecursive(
    sf.optional(sf.string),
    "untitled",
  ),
  child: sf.optionalRecursive([() => TreeNode]),
}) {}

// `label` is optional in the constructor — the default is used when omitted
const leaf = new TreeNode({ value: 1 });
// leaf.label === "untitled"

const root = new TreeNode({ value: 0, label: "root", child: leaf });
// root.label === "root"
// root.child.label === "untitled"

Warning: Be careful about using the recursive type itself as a default value — this is likely to cause infinite recursion at construction time, since creating the default value would trigger the same default again. Instead, use a primitive or a separate node type as the default:

const DefaultTag = sf.objectRecursiveAlpha("Tag", {
  id: sf.number,
  child: sf.optionalRecursive([() => TreeNode]),
});

class TreeNode extends sf.objectRecursiveAlpha("TreeNode", {
  value: sf.number,
  // ✅ Safe: default is a non-recursive node
  tag: SchemaFactoryAlpha.withDefaultRecursive(
    sf.optional(DefaultTag),
    () => new DefaultTag({ id: 0, child: new DefaultTag({ id: 1 }) }),
  ),
  child: sf.optionalRecursive([() => TreeNode]),
}) {}

The following definition for child would cause infinite recursion at construction time:

child: SchemaFactoryAlpha.withDefaultRecursive(
  sf.optionalRecursive([() => TreeNode]),
  () => new TreeNode({ value: 0 }),
);

The infinite recursion can be solved by passing in undefined explicitly but it is recommended to not use defaults in this case:

child: SchemaFactoryAlpha.withDefaultRecursive(
  sf.optionalRecursive([() => TreeNode]),
  () => new TreeNode({ value: 0, child: undefined }),
);

Type safety

The default value (or the value returned by a generator function) must be of an allowed type for the field. TypeScript enforces this at compile time:

// ✅ Valid: number default for number field
sf.withDefault(sf.optional(sf.number), 8080);

// ✅ Valid: generator returns string for string field
sf.withDefault(sf.optional(sf.string), () => "localhost");

// ❌ TypeScript error: string default for number field
sf.withDefault(sf.optional(sf.number), "8080");

// ❌ TypeScript error: generator returns number for string field
sf.withDefault(sf.optional(sf.string), () => 8080);

Change details

Commit: 44fdd94

Affected packages:

  • fluid-framework
  • @fluidframework/tree

⬆️ Table of contents

🛠️ Start Building Today!

Please continue to engage with us on GitHub Discussion and Issue pages as you adopt Fluid Framework!

Don't miss a new FluidFramework release

NewReleases is sending notifications on new releases.