-
Notifications
You must be signed in to change notification settings - Fork 532
Coding Guidelines
Coding guidelines for the Fluid Framework take a couple of forms. Where possible, we enforce these rules in an automated fashion via tooling like our shared build configurations and shared ESLint configuration.
Where guidelines cannot be enforced by tooling, please refer to the following guidelines. And when in doubt, or when explicit guidance is not offered here, refer to TypeScript's own guidelines.
The following are guidelines for crafting framework APIs that are likely to be well-received by TypeScript and JavaScript application developers. The purpose of this document is to accelerate the API design and review process by helping framework engineers build a common understanding of the needs of the typical frontend developer.
The primary audience for this document is engineers and PMs designing client-side framework APIs that may interoperate with other modern frontend technologies. It assumes the framework targets Evergreen Browsers and Progressive Web Applications (PWAs). This document does not consider limitations of older browsers or the differing conventions of alternative environments like Node.js and React Native.
This document is intended to complement a healthy API design and review process. It is not a substitute for customer profiling, scenario-driven design or customer feedback. However, the reality is often that the budget for user research is limited, and it can be challenging to get users to evaluate alpha and beta releases.
As a result, it is often only late in the development cycle that framework engineers begin to get a trickle of feedback regarding their API choices. Adopting the guidelines in this document will help avoid wasting this trickle addressing predictable ergonomics issues. Additionally, adopting common API patterns makes our framework more accessible to both external and internal developers.
We are now putting these guidelines into practice in the Fluid Framework codebase, while at the same time continuing to grow this document based on feedback and specific cases encountered in the product.
A common pitfall for framework engineers is to assume that their preferences accurately mirror the needs of their end user. The optimal balance of perfection vs. rapid release vs. overall engineering cost is different for frameworks and applications. Consequently, framework engineers often mis-calibrate when it comes to API tradeoffs such as:
- flexible vs. simple
- elegant vs. obvious
- strict vs. agile
- general vs. familiar
- explicit vs. concise
- high-control vs. low-friction
If the qualities listed on the left resonate most strongly with you, you’re likely a framework engineer. One technique for counter-balancing the natural tendency to over-engineer is to recognize that most user requests begin in the form “I just want X so I can Y."
A common pitfall that framework engineers fall into is requiring the end user to jump through unnecessary hoops for the sake of user education. Most engineers understand that a learning curve implies that users will not initially be experts. However, it is often overlooked that this implies that users must be allowed to use the framework without fully understanding what they are doing. This includes allowing the user to use the framework in suboptimal ways.
For example, a framework engineer might argue that Array.indexOf
has a non-obvious O(n)
cost.
They might argue that it’s better to omit this convenience as to encourage users to think carefully about alternatives (binary search, a more suitable data structure, etc.) as they hand code a for-loop.
What this argument fails to consider is that even if suboptimal, users find indexOf
to be convenient and the results are often “good enough” for the user’s scenario.
When considering forbidding common practices (such as linear search or using strings to represent UUIDs), think carefully about whether the user is likely to understand the motivation for the friction and agree that the loss of convenience is justified by the potential for error.
The most successful technologies are those that can be adopted incrementally, are reasonable to retrofit into existing architectures, and build on familiar patterns and concepts.
Front-end developers prefer frameworks that interoperate via plain data and callbacks as opposed to formal contracts. Even for greenfield projects, developers (and companies) are averse to tying themselves foundationally to a specific technology, architecture, or pattern.
Clever patterns and elegant generalizations initially have negative value if they need to be explained. To be net positive, the return on investment of learning a new pattern or concept needs to be immediate and obvious to the end user.
Remember that a frontend developer's attention is divided among the dozens of packages they are weaving together to ship a product. Their goal is to learn "just enough" about your framework to understand if it solves their current problem.
When crafting a new API, look to the standard built-in types and DOM for inspiration, as these are ubiquitous and likely familiar to your user. You should give more weight to recent API additions as these better reflect modern practices.
Avoid throwing runtime errors when there is a reasonable and "obvious" default behavior. This is especially true if there is precedent in the standard built-in types or DOM.
The JavaScript ecosystem relies on developers to provide snippets of glue code to connect packages authored by different parties. This is how JavaScript achieves a high degree of code reuse without requiring prearranged contracts.
Developers expect this glue code to be concise and do not appreciate being required to anticipate, check for, and explicitly handle boundary conditions. It is rare that a front-end developer complains about an API “swallowing errors” and generally views helpful coercion as part of the framework’s value.
// Not an error: non-existent items in requested slice are elided
[].slice(0, 10); // -> []
// Not an error: slicing zero items returns an empty array
[0, 1, 2, 3].slice(0, 0); // -> []
Any default behavior(s) should be clearly documented in the API's source-code documentation.
With each release, the TypeScript type system becomes more expressive, and we should leverage the capabilities of the type system to enhance developer productivity with accurate IntelliSense and helpful compiler errors.
However, it is important to remember that the purpose of TypeScript type checking in public APIs is to be helpful to the end developer. “Being helpful” is slightly different than “enforcing correctness”. Typing that creates friction, clutters imports, or degrades readability in IntelliSense or compiler errors is not perceived by developers as helpful, even if it is strictly “more correct”.
This is simple:
from<T>(array: ArrayLike<T>): T[];
This is a little advanced, but still okay:
from<T, U>(array: ArrayLike<T>, mapFn: (v: T, k: number) => U, thisArg?: any): U[];
This complex/advanced and probably shouldn't appear in a user-facing API:
type CopyablePrimitives = null | boolean | number | string
type Copyable<T> = T extends CopyablePrimitives | { [brand]: "Copyable<T>" } ?
T : never;
type Copied<T> = T extends CopyablePrimitives | { [brand]: "Copied<T>" };
from<TType, TIn extends Copyable<TType>, TOut extends Copied<TType>>(
array: ArrayLike<TIn>,
mapFn: (v: TIn, k: number): {
action: "skip" | "stop"
} | {
action: "continue"
value: TOut
}, thisArg?: any): TOut[];
Generic parameters are one of the most common sources of the "advanced" or "complex" types cautioned against just above. In general, generics should be used with prudence and only if they truly serve a purpose that can't be accomplished another way.
Here is an example of an appropriate use of a generic parameter:
/** Add a label to an object */
function label<T extends object>(obj: T, label: string): T & { label: string } {
Object.defineProperty(obj, label, {value: label})
return obj as T & { label: string };
}
In this case, the function receives an object of an unknown type from the user and also returns that object back to the user. This is a proper use of generics - to preserve output types from input types. The same applies to class members.
Here is an example of an unnecessary use of a generic parameter:
/** Returns true if and only if the given object has a label */
function hasLabel<T extends object>(obj: T): boolean {
return (obj as { label?: string }).label !== undefined;
}
The generic type T
is only used as an input type - the type that it captures is never re-used later as output.
Therefore, the function can be written equivalently as follows:
/** Returns true if and only if the given object has a label */
function hasLabel(obj: object): boolean {
return (obj as { label?: string }).label !== undefined;
}
Always double check that each generic parameter you introduce has a meaningful and distinct purpose that can't be accomplished any other way.
Because function overloading is inconvenient to implement in the JavaScript language, framework engineers often avoid it. However, overloading is a powerful tool for reducing IntelliSense clutter and helping developers discover alternative ways to express the same operation.
This document is not intended to be a style guide, but when questions of style arise, the style used by examples in The TypeScript Handbook is a good representation of the prevailing conventions used by TypeScript developers:
- Use
PascalCase
for type names. - Use
PascalCase
for enum values. - Use
camelCase
for function, property, and variable names.
While a common convention in some codebases, this naming convention has a few notable downsides:
-
interface
s andtype
s are often used interchangeably, and changing an existing entity between the two is common in refactoring. Renaming an item each time its underlying kind changes is needlessly disruptive. -
I
prefixing encourages naming an interface for its implementation, rather than deriving implementation names from their semantic contracts (i.e., interfaces).
When introducing an interface with a single implementation (e.g., to hide implementation details from an API), prefer prefixing/postfixing the implementation rather than prefixing the interface. E.g.,
Prefer...
export interface Foo {...}
class DefaultFoo implements Foo {...}
instead of...
export interface IFoo {...}
class Foo implements IFoo {...}
This allows the public thing (the interface) to have the nice semantic name, and the private implementation detail is burdened with the more nuanced naming.
In general, give type parameters full names, rather than just typing T.
E.g., prefer TFoo
to T
.
In trivial cases, or when the type parameter is simply passed through to another type, T
by itself is allowed.
E.g.
// It's clear from context that the type here corresponds to the elements of the array.
// Using `TElement` isn't necessary.
export function sort<T>(input: T[]);
But in general, if you can provide useful semantics in a type name, you should.
And remember to document the parameters using a @typeParam TSDoc comment block.
The T
prefix is industry-standard and is helpful in allowing developers to easily differentiate type parameters from other types at-a-glance.
Prefer...
type SpecialMap<TKey, TValue> = ...
instead of...
type SpecialMap<K, V> = ...
or...
type SpecialMap<Key, Value> = ...
Naming functions, methods, callbacks, etc. using verb phrases helps to differentiate semantic objects from semantic actions at-a-glance.
E.g., prefer createFoo()
over foo()
.
Think hard about the first word people will try typing in the code editor when they explore the feature area. Try to make the first response in IntelliSense the correct entry point to the corresponding feature.
Favor short and memorable names over longSelfDocumentingNames for important and frequently used APIs. In the JavaScript community, developers assume the shorter the name the more important the API. Long multi-word names are assumed to be rarely used or semi-internal.
- Remember to document any aspects that are not captured by the short name in the API documentation!
Make use of well-known abbreviations (but only well-known abbreviations). A good rule of thumb is to use the abbreviated form if it's what you would say out loud (HTML, JSON, min/max, etc.).
- Remember to think in terms of accessibility. Would a someone who is not a native English speaker understand the abbreviation? Would someone outside of Microsoft understand it?
A good example is EventEmitter’s on()
and off()
pattern, which has largely become the de-facto standard among frameworks.
An anti-example is the DOM’s built-in addEventListener()
and removeEventListener()
, which frameworks rarely emulate.
Another anti-pattern is function/property names that unnecessarily echo return types or argument types/names.
For example, prefer insert(item: Node)
to insertItem(itemNode: Node)
.
Examples:
Prefix | Examples | Meaning |
---|---|---|
as | asNode | Casts the subject to a different interface, preforming a type check if necessary. Differs from 'to' in that no data is copied. The returned object is a different facet of the original. |
Default | DefaultRuntime | Designates the built-in/commonly used implementation of an interface. |
is/has | isObject, isConnected, hasChildren | Returns a Boolean indicating if the subject is an instance of a type, implements an interface, or is currently in specific state. Prefer 'is' except when unnatural. For example, prefer 'isConnected' to 'hasConnection' and prefer 'isInitialized' to 'hasInitialized'. |
to | Object.toString() | Constructs a new instance from the given argument(s) via a shallow or deep copy (contrast with ‘as’). Same as ‘from’, but resides as a static method on the type being constructed. |
from | Array.from() | Constructs a new instance from the given argument(s) via a shallow or deep copy (contrast with ‘as’). Similar to a constructor, but implies a conversion of one type to another. Same as ‘to’, but resides as a static method on the type being constructed. |
There are many boundaries along which a framework engineer might partition packages: layering, ownership, release group, etc. All of these are generally for the convenience of the framework engineer, not the end user. When publishing for the end user, the ideal number of packages is usually one.
There are a couple of exceptions:
Either-or scenarios where an application will import 1 of n packages.
Examples include:
- Choosing between a production or development version of the framework
- Choosing between React or Svelt interoperability
- Choosing between Azure or Syntex as a backend
Packages that are versioned separately for the end user’s convenience.
For example, eslint
is versioned independently from its various plugins.
This helps to clearly differentiate architectural changes from content (i.e., rules) changes that might affect the user.
Note that if the incentive for partitioning into multiple packages is bundle size or IntelliSense clutter, you probably have a different problem.
The JavaScript ecosystem interoperates primarily through trees of plain data. Encapsulating data within an opaque object model creates friction when interfacing with 3rd party packages. When possible, favor consuming and produce trees of JSON-compatible types.
To interoperate with existing backend and frontend technologies, data contracts should be defined using JSON-compatible types. These are:
- Plain objects (no prototypes, string keys only, single reference)
- Dense arrays using keys 0..length-1 (no buffers, views, or typed arrays)
- Strings (valid Unicode only)
- Finite Float64 numbers (no Infinity, NaN, -0, or BigInt) *Booleans
- Implicit undefined (optional properties elided by JSON serialization)
- Null (as empty root or placeholder in arrays – see next guideline.)
This subset of JavaScript types forms a minimal but complete data model that is well supported across data stores and transport protocols.
GraphQL is a good example of a framework that embraces JSON as the “lowest common denominator”. It's worth studying if you’re interested in applying this principle in a cross-language environment.
undefined
is the preferred type for uninitialized variables, missing keys, or a sentinel representing an empty state.
Optional properties and arguments, which are implicitly undefined
, are generally preferred over explicit undefined
.
There are, however, a few cases where null
continues to be appropriate.
The primary use case is JSON serializable data where "implicit undefined" is not an option.
These are:
- The root of an empty tree ("null”)
- An empty placeholder in arrays ("[null, 3]")
In the JavaScript language it is possible to distinguish between a non-existing property and a property that has been explicitly set to the undefined
value.
You should interpret these identically when reading.
Do not go out of your way elide explicit undefine
s, except in cases where it improves efficiency (such as serialization).
Otherwise, you should follow the natural behavior of Object.keys()
, foo = bar
, etc. which will preserve the explicit undefined.
The prevailing convention is to use Object.keys()
when iterating or copying data, which includes only string-keyed enumerable properties that are owned by the object (not inherited).
Note that it is expected that private metadata attached via symbol
keys will be elided when iterated, copied, transmitted, or persisted.
While JavaScript lacks direct support for "named arguments", it is possible to them by using an object literal "property bags":
server.connect({ url: string, port: number, compression: Compression });
Use of the "named arguments" style is a balance between legibility/extensibility and developer convenience. In general, "named arguments" should be preferred when...
- An API is often invoked with a large number of parameters, especially when many of those parameters share the same type(s) (e.g.,
init(3, 300, 4, 7)
). - An API accepts multiple optional arguments.
- An API is likely to benefit from future extensibility (e.g., new options).
In general, "named arguments" should be avoided when...
- An API accepts a small number of parameters that are believed to be fixed.
- An anti-example would be use named arguments for
.slice({ start: 0, end: 10 })
. For frequently used APIs with small numbers of arguments, client developers prefer a well-understood convention.
- An anti-example would be use named arguments for
Note: the use of "named arguments" can have performance implications, as it requires an object allocation for each call. Generally, this is not an issue for our exposed API surface, but internal APIs which are performance-critical should carefully consider if the costs of this pattern outweigh the benefits.
Avoid using combinations of property bags when using this pattern. Prefer a single object, unless there is a clear logical division between the bags. The use of multiple property bags makes it confusing for a caller to understand/remember where an individual argument goes.
Bad:
function foo(someProps: FooProps1, moreProps: FooProps2) {...}
Never pass a bag on to another function. This may overexpose members of the bag and/or lead to coupling of the two functions. Prefer to destructure the bag immediately in the function to reduce this temptation and also to reduce repetition.
Good:
function foo(props: IFooProps) {
const { prop1 } = props;
bar(prop1);
}
function foo({ prop1 }: IFooProps) {
bar(prop1);
}
Bad:
function foo(props: IFooProps) {
bar(props);
}
Runtime error checking is reserved for non-obvious errors that are difficult to diagnose without runtime assistance. You should assume that developers have common sense and do not exploit quirks or intentionally circumvent the type system.
An assertion failure indicates a bug in the Fluid Framework itself, not the user's code.
User errors should by signaled by throwing an instance of Error
, TypeError
, ReferenceError
, RangeError
, AggregateError
or an appropriate subclass (other built-in error types are reserved for language parsing errors).
- See the @fluidframework/core-interfaces package for some example Error subclasses used frequently across the framework.
Consistency is valuable for presenting a legible API. Please don't reinvent the wheel. Wherever possible, leverage the eventing libraries exported by the core-interfaces package.
When this is impractical...
The prevailing convention among JavaScript frameworks is to adopt EventEmitter’s on/off pattern for register/deregistering event listeners. The “on” method should be strongly typed over the events known to the emitter (e.g., via https://43081j.com/2020/11/typed-events-in-typescript). It should only allow registrations for known event names, and the corresponding listener functions should be required to have compatible parameters.
While subclassing the EventEmitter type may be a convenient way to implement on/off, it also publicly exposes methods that are intended for private use by the event producer (not the consumer).
The methods recommended for event consumers are:
- on
- off
- once
Other capabilities, such as the ability to emit events, enumerate listeners, detect additional and removal of listeners, etc. should be privately held by the producer.
Include parameters that capture data which is going away (e.g., “previousValue” for a “change” event). Don’t include parameters that can be easily read from the emitting object itself (e.g., “newValue”). Likewise, don’t include the object emitting the event as a parameter in the listener (e.g., “sender”, “target”); it is easy for the consumer to capture the emitter in the event listener function if they need to.
A subscriber of an event should be able to assume that their event remains registered until they deregister it themselves. Registered events should always fire in the order that they were registered. Though relying on the ordering is often an anti-pattern, a consistent ordering is nonetheless expected by consumers and should be preserved.
The emitter should not have any behavior (other than recording/removing the listener) when a listener is registered/deregistered. It should also avoid enumerating or counting its registered listeners.
We leverage TSDoc syntax for TypeScript API-level source code documentation. See our TSDoc guidelines for helpful tips leveraging the syntax.
Documentation for our APIs is publicly available on fluidframework.com. Remember that the documentation you write may be user-facing, so it needs to be accessible and useful!
Remember that many of our APIs are or will be visible to other developers, including developers external users. To ensure our documentation is useful and accessible to the widest audience, our documentation should be written in such a way that it is easily readable by any English reader.
As a general rule, if an API contract cannot be captured by the type-system, then it should be documented. For example:
/**
* Gets the element at the provided index.
*/
public getAtIndex(index: number): Foo;
At the type-system level, the input index
can potentially be negative, infinite, etc.
What our method does in these cases is unclear.
Does it throw?
Does it return some default value?
There are a few syntactic options for conveying this sort of information - use your best judgment when determining what to use. A better option for our example method might look something like:
/**
* Gets the element at the provided index.
* @param index - The index being queried. Must be on [0, {@link Bar.length}).
* @throws Throws an error if the provided index is out of range.
*/
public getAtIndex(index: number): Foo;
This wiki is focused on contributing to the Fluid Framework codebase.
For information on using Fluid Framework or building applications on it, please refer to fluidframework.com.
- Submitting Bugs and Feature Requests
-
Contributing to the Repo
- Repo Basics
- Common Workflows and Patterns
- Managing dependencies
- Client Code
- Server Code
- PR Guidelines
- CI Pipelines
- Breaking vs Non-Breaking Changes
- Branches, Versions, and Releases
- Compatibility & Versioning
- Testing
- Debugging
- npm package scopes
- Maintaining API support levels
- Developer Tooling Maintenance
- API Deprecation
- Working with the Website (fluidframework.com)
- Coding Guidelines
- Documentation Guidelines
- CLA