Typescript Conventions and Best Practices for Meta Horizon Worlds
TypeScript is a robust and full-featured computer programming language that is derived from JavaScript (also known as ECMAScript). Like most computer languages, it does not come with any official formatting guidelines to enforce consistency, and, due to its legacy in JavaScript, contains some language features that are best just avoided.
This document will endeavor to present a consistent formatting style for use by Meta Horizon Worlds TypeScript developers, while listing best practices that TypeScript developers ought to follow in order to avoid falling into some of the pitfalls of the language that either are just ‘bad’ or do not translate well to the Meta Horizon Worlds environment.
Prerequisites and Expectations
This is not a document about ‘learning typescript’ or even about ‘learning the Meta Horizon Worlds typescript api’. It is expected that you are coming to this document with some experience with programming languages, and some experience in writing scripts for Meta Horizon Worlds.
Following conventions and best practices are not required for writing code in Meta Horizon Worlds. The purpose of conventions and best practices is to make your code easier for others to understand (and for you to understand other people’s code more easily), and to avoid common mistakes that are caused by formatting ambiguities or language features that are easy to use incorrectly. Some choices for conventions can seem a bit arbitrary, but the most important thing about conventions is that some decisions are made and that people try to follow them whenever possible.
The conventions and best practices are written using
RFC 2119 terminology (‘may’, ‘should’, ‘must’, etc). In other words, some of the recommendations are stronger than others and should almost always be followed, while others are perhaps a bit more difficult to apply consistently or are considered ‘controversial’.
This document has a shorter Minimal Recommendations section that, if you adopt nothing else, you should at least do, followed by an Extended Recommendations section that has a more robust set of rules.
Finally, if you are using an IDE like VSCode or Webstorm or similar to write your typescript code (rather than, say Notepad or something), there are freely available plugins that you can install that will handle many of these recommendations for you automatically (and probably even more!). If you’re impatient, skip to the
TL;DR Plugins section, install those tools, and be on your way.
Section 1: Minimal Recommendations
Never use tabs for indenting your code. Different people may have different tab stop settings and when they invariably get mixed in with people who indent using spacing, the formatting can become a mess.
Use consistent indentation amounts Use the same indentation level amounts in your code. Do not indent some levels by three spaces and others by seven spaces and others by zero spaces. The typical indentation amount for typescript code is 2 spaces per level.
Do this:
function myFunction() {
if (someCondition) {
// do some things
// do some other things
} else {
for (let i = 0; i < 10; i++) {
// do something 10 times
}
}
}
Not this:
function myFunction() {
if (someCondition) {
// do some things
// do some other things
} else {
for (let i = 0; i < 10; i++) {
// do something 10 times
}
}
}
Always use curly braces on control flow blocks When you have a sub-block of code, such as with an if
, else
, for
, or while
block, always use curly braces to enclose it. Critical errors can be prevented by always enclosing blocks in curly braces rather than leaving them hanging naked where someone might erroneously add a line that executes when unexpected.
Do this:
if (someBoolean) {
return;
}
while (keepLooping) {
inLoop();
}
for (let i = 0; i < 10; i++) {
doLoop();
}
Not this:
if (someBoolean)
return;
while (keepLooping)
inLoop();
for (let i = 0; i < 10; i++)
doLoop();
Separate adjacent methods in a class with a blank line It is difficult to see the end of one method and the start of another if they run together without an empty line break in between them.
Do this:
class MyClass {
method1() {
// do stuff
}
method2() {
// do other stuff
}
}
Not this:
class MyClass {
method1() {
// do stuff
}
method2() {
// do other stuff
}
}
Don’t use string continuations, instead append strings Do this:
const string = "A very long string" +
"crossing multiple lines."
Not this:
const string = "A very long string\
crossing multiple lines."
If you don’t know the type of something, use unknown
. Use of the any keyword turns off typescript’s type checking, which defeats a large amount of the benefit of using typescript over javascript.
Do this:
const somethingUnknown: unknown
Not this:
const somethingUnknown: any
Use let
or const
for all variable declarations. The use of var
has unexpected scoping consequences that can cause difficult to track down bugs.
Do this:
const trueResult = 2
let value: number = 0
if (somethingTrue) {
value = 2
}
console.log(value)
Not this:
var trueResult = 2
if (somethingTrue)
var value: number = result
}
console.log(value)
Likewise, the as
keyword is you telling typescript that the type is different than it thinks, and partially defeats the typing system. Instead, use an if
statement to test that the object is an instanceof
the class you expect.
Assuming these definitions:
class SomeType {
}
class SubType extends SomeType {
doThing() {
// subtype thing
}
}
Do this:
function doIfSubType(thing: SomeType) {
if (foo instanceof SubType) {
foo.doThing();
}
}
Not this:
function doIfSubType(thing: SomeType) {
(thing as SubType).doThing();
}
There are a few rare cases where using as
is important, such as when you mark an explicit structure as const
to indicate the values cannot be changed.
const Events = {
myEvent1: new LocalEvent('event1'),
myEvent2: new LocalEvent('event2'),
} as const;
Note: This does not include a prohibition on the Entity.as()
method, of which use is necessary to convert Meta Horizon Worlds Entity types to their corresponding Gizmos.
Similar to using any
, the use of !
tells typescript to ignore its typing checks because you know better that the variable is actually defined. Typescript is very good at tracking the types of variables, so if you find yourself forcing typescript to treat something as defined that it thinks may not be, ask yourself if you really understand what’s going on.
Preferably, rewrite your code using an if
statement to check for undefined
or null
(or nullish values) so that the type guard will remove the necessity of using !
. Also, use ?, when possible, to short circuit statements that might be undefined
.
Do this:
if (thing !== undefined && thing !== null) { // explicit check
thing.callFunction();
}
if (thing) { // check for nullish / falsy value
thing.callFunction();
}
thing?.callFunction(); // optional chaining
Not this:
Unfortunately, the props
on Components may be undefined if nothing was wired up to them. You really ought to check they are not undefined before you use them. At the very least, check in start()
or preStart()
that all the props
are defined and if not, emit a very large console.error() and throw an exception before you go adding this.props.myProp!
In other methods in the class.
Example:
class MyComponent extends Component<typeof MyComponent> {
static propsDefinition = {
prop1: { type: PropTypes.Entity },
prop2: { type: PropTypes.Entity },
}
override preStart() {
if (!this.props.prop1 || !this.props.prop2) {
console.error("prop1 or prop2 are not set!")
throw new Error("fatal config error") // kills the script at world start
}
// no need for ! here because the if test above protects this statement
this.connectLocalEvent(this.props.prop1, SomeEvent, () => { // some action });
}
override start() {
// safe-ish to use ! here because start() will never be called if preStart() throws an Error killing the script
this.sendLocalEvent(this.props.prop2!, SomeEvent, {});
}
}
Avoid using Number, Boolean, String, Object as types These capitalized versions of number, boolean, string, and object are wrappers for the primitive types, and only need to be used as types for special purposes. You almost always want to avoid the wrappers and use the primitive types instead.
Do this:
const someNum: number = 3
function doThing(someString: string): boolean
Not this:
const someNum: Number = 3
function doThing(someString: String): Boolean
These classes do have a lot of useful functions for manipulating Numbers, Strings, etc. It is ok to use those functions on the classes.
// ok
if (Number.isInteger(someNumber)) {
// do integer things with someNumber
}
// ok
const oneHalf = String.fromCharCode(189)
Never use == or != for equality checks (Use === and !== instead) Never use the == and != operators to compare values. They will sometimes do some unexpected type conversions and will return true or false when you would think they would do the opposite.
Do this:
if (someNumber === 3 && someString !== "foo") {
// do stuff
}
Not this:
if (someNumber == 3 && someString != "foo") {
// do stuff
}
Never do assignment in a conditional statement Never assign a variable value using = in a conditional statement. It leaves people wondering whether you really meant to use an equality check rather than assignment. Also, a side effect of assigning a value within a conditional statement is unexpected and makes your code difficult to understand.
Do this:
let storedValue: boolean;
function setAndCheckValue(value: boolean) {
storedValue = value;
if (storedValue) {
// do thing
}
}
Not this:
let storedValue: boolean;
function setAndCheckValue(value: boolean) {
if (storedValue = value) {
// do thing
}
}
Use ?? rather than || for defaults on undefined values This is similar to using === and !==, as the automatic type conversion of | | will sometimes convert things into a binary false value that you don’t expect. Using ?? makes sure that only undefined and null values are treated as false. |
Do this:
function defaultIfUnset(value?: number): number {
return value ?? 5;
}
Not this:
function defaultIfUnset(value?: number): number {
return value || 5;
}
The for…in
construct loops over the keys of an array, which is rarely what you want. Instead, use for…of
, or forEach()
Do this:
function printNumberMembers(values: number[]) {
for (const value of values) {
console.log(value);
}
}
function printStringMembers(values: string[]) {
values.forEach(value => {
console.log(value);
})
}
Not this:
function printBooleanMembers(values: boolean[]) {
for (const index in values) {
console.log(values[index]);
}
}
Do not compare booleans to true
or false
Boolean values are already true or false, so it makes no sense to compare them to boolean literals.
Do this:
if (myBoolean1 && !myBoolean2) {
// do stuff
}
Not this:
if (myBoolean1 === true && myBoolean2 === false) {
}
Do not write non-trivial fallthrough case statements If you have a switch/case
statement, it is a common error to forget to write break
after each case
. The only instance where it is acceptable to not have a break for each case is if that case has zero statements, and you are just trying to group a bunch of common cases together into exactly the same action. Otherwise, always put in a break
at the end of each case block (including default).
Do this:
switch (someValue) {
case 1:
doThing1();
doThing2(); // explicitly do thing2 here that also applies to 1
break;
case 2:
doThing2();
break;
case 3: // ok to fall through because empty case
case 4:
doThing3And4();
break;
default:
doDefault();
break;
}
Not this:
switch (someValue) {
case 1:
doThing1();
// yucky fall through to case 2
case 2:
doThing2();
break;
case 3:
doThing3And4(); // possibly unnecessary duplication
break;
case 4:
doThing3And4();
break;
default:
doDefault();
// missing break on default
}
Always include a default in switch statements The last case in a switch statement should always be default
, even if empty. If it is invalid for it to be reached because all other combinations should have been handled, it must at least log a warning or error to the console, if not throw an exception.
Do this:
switch (someValue) {
case 1:
doThing1();
break;
case 2:
doThing2();
break;
default:
console.warn("unhandled case", someValue)
break;
}
Not this:
switch (someValue) {
case 1:
doThing1();
break;
case 2:
doThing2();
break;
} // if you add a new possible value later, you may miss handling the case!
Do not use the {} type as an opaque type The {} type is a bit like any
, but for objects. When you don’t know the type, use unknown
. If you mean a dictionary, use Record<string, unknown>. If you mean an object, use object
.
Do this:
let someUnknownThing: unknown;
let someRecordThing: Record<string, Type>;
let someObjectThing: object;
Not this:
The constructor arguments for the Array class are not terribly consistent and are confusing to use correctly.
Do this:
const myPrefilledArray = Array.from<number>({ length: 5 }).fill(3)
const myArray = [5, 6, 7]
Not this:
const myArray = new Array(true) // 1 element boolean[] array containing [ "true" ]
const myArray = new Array(5) // 5 element empty array of any[] type
const myArray = new Array(5, 6, 7) // 3 element number[] array containing [ 5, 6, 7]
Only use 7-bit alphanumeric characters and _ for identifiers Do not use unicode characters in identifiers, or other special characters (such as $). Some unicode characters are very difficult to distinguish from 7-bit ascii.
Do not use bind() when registering event handlers, use arrow functions Due to a design flaw in the LocalEvent and NetworkEvent types, the typescript compiler cannot always correctly type check the parameters for functions bound to event handlers if you use bind(). Instead, use an arrow function for registering the event. You can either explicitly pass the data object payload to a handler function, or destructure the data object so that the handler function signature is more convenient.
Given this definition:
const myEvent1 = new LocalEvent<{thing: Entity, name: string}>('myEvent1');
const myEvent2 = new NetworkEvent<{ player: Player }>('myEvent2');
Do this:
override preStart() {
this.connectLocalEvent(this.entity, myEvent1, data => this.onEvent1(data));
this.connectNetworkEvent(this.entity, myEvent2, ({player}) => this.onEvent2(player));
}
onEvent1(data: {thing: Entity, name: string}) {
// argument types will be checked in connect() call
console.log(data.thing, data.name);
}
onEvent2(player: Player) {
// we destructured the payload when connecting the event
console.log(player.name.get());
}
Not this:
override preStart() {
this.connectLocalEvent(this.entity, myEvent1, this.onEvent1.bind(this));
this.connectLocalEvent(this.entity, myEvent2, this.onEvent2.bind(this));
}
onEvent1(data: {stuff: string, value: number}) {
// this will compile but the values will be undefined
console.log(data.stuff, data.value);
}
onEvent2(player: Player) {
// this will compile, but player will not be a Player.
console.log(player.name.get());
}
Note: This problem does not affect CodeBlockEvents, but for consistency, you should probably register all your event handlers the same way to avoid mistakes.
Don’t connect to events in start()
, use preStart()
To avoid race conditions, make sure you run your connect*Event()
code in preStart()
rather than start()
. As, at world start, the system runs all of the preStart()
calls in all Components before start()
is called in any Component, you will be sure that all event listeners are registered before anyone tries to send an event to them.
Do this:
override preStart() {
this.connectLocalEvent(this.entity, someEvent, () => console.log('event received'));
}
override start() {
}
Not this:
override preStart() {
}
override start() {
this.connectLocalEvent(this.entity, someEvent, () => console.log('event received'));
}
Never send events in preStart()
, use start()
instead. Similar to the above, never call send*Event()
in preStart()
, as some Components may not have set up event listeners yet and will miss the event if you send it too early.
Do this:
override preStart() {
}
override start() {
this.sendLocalEvent(this.props.target, someEvent, {});
}
Not this:
override preStart() {
this.sendLocalEvent(this.props.target, someEvent, {});
}
override start() {
}
Define Events once and export to all users Do not re-declare the same CodeBlockEvents, LocalEvents, or NetworkEvents in different places in the code. Have one ‘home’ for each event declaration, export it from there to other users, who should import that definition. This way, you can be sure that everyone agrees on the string name of the event, the type of the event (CodeBlock, Network, or Local), and, most importantly, the parameters of the event. This is especially important if you are changing the event definitions during the development process.
One common pattern is to have a single Events.ts module and dump all the events for the entire world in there as exported objects.
Events.ts
import { LocalEvent, NetworkEvent, CodeBlockEvent } from "horizon/core";
export const MyEvents = {
event1: new LocalEvent("event1"),
event2: new NetworkEvent("event2"),
event3: new CodeBlockEvent("event3", []),
};
MyComponent.ts
import { Component } from "horizon/core";
import { MyEvents } from "Events";
class MyComponent extends Component<typeof MyComponent> {
preStart() {
this.connectCodeBlockEvent(this.entity, MyEvents.event3, () => { });
}
start() {
}
}
Another approach is to have each event in the module of the primary ‘listener’ and have it export the event so that all senders can import the definition.
A note for people using the Horizon Hub ‘waffle-converter’: due to the fact that it doesn’t have ‘multi-file’ knowledge of your system, it places all CodeBlockEvent definitions locally in each module where they are sent or received. You will need to refactor this code to ensure there is only one definition of each event in your world by moving the definitions to a common location.
Prefer NetworkEvents and LocalEvents over CodeBlockEvents While CodeBlockEvents continue to work in TypeScript, they are less efficient to send and receive, and have much more limited payload types than either NetworkEvents or LocalEvents.
NetworkEvents can send structured data that cannot be sent in CodeBlockEvents, and, like CodeBlockEvents, can be sent across the network between different script owners.
LocalEvents can only be sent to the same script owner, but, unlike NetworkEvents, can send practically anything as a payload.
Note that NetworkEvents arrive, at earliest, in the next frame, while LocalEvents are sent immediately and are more like function calls.
Always quote asset ids as strings When using asset ids to construct Asset objects in code, always treat them as strings, never as numbers. The id space of assets is 2^64, but the maximum integer for a number is 2^56, so there are some asset ids that can’t be represented as numbers.
Do this:
const myAsset = new Asset(BigInt("123456678901234"))
Not this:
const myAsset = new Asset(Bigint(12345678901234))
As a general rule, don’t subclass classes that are not marked abstract
, as they may not be designed to work properly as subclasses. An abstract
class must be subclassed to be instantiated, so generally it is safe to do so to implement the abstracted parts of the class definition. Both Component and UIComponent are abstract classes (Component has an abstract start()
method, and UIComponent has an abstract initializeUI()
method).
So, do not subclass Vec3, Quaternion, Color, Asset, Entity, etc. as the API is probably not set up to handle your subclassed versions of these classes correctly in all circumstances or for all future releases.
Section 2: Extended Recommendations
Use whitespace liberally (there is no fee for use!) Insert a space after all keywords (if, while, for). Insert a space after commas. Insert spaces around binary operator (+, -, *, /). Use newlines to separate logical blocks of code.
Do this:
for (let i = 0; i < 10; i++) {
callFunction(arg1, i / 10 + 20);
}
while (notDone()) {
const check: boolean = doCheck();
if (check && !globalError) {
console.log("status is:",status)
}
Not this:
for(let i=0;i<10;++){
callFunction(arg1,i/10+20)
}
while(notDone()){
const check:boolean=doCheck();
if(check&&!globalError){
console.log("status is:",status)
}
}
Put control flow blocks on their own line Always put sub-blocks (such as with if
, else
, for
, etc) on the next line. Do not write single line if
statements.
Do this:
if (condition) {
doAction();
}
Not this:
if (condition) doAction();
Use semicolons to end statements Semicolons at the end of statements are semi-optional in typescript. Automatic Semicolon Insertion (ASI) by the compiler usually avoids the need to add them explicitly. However, sometimes they are needed to either make the code compile or avoid a subtle bug. Thus, it’s generally best to get in the habit of always including them to avoid problems.
Use const
rather than let
on local/global variables whenever possible When declaring variables use const
unless you later determine that you need to edit the value. Using const allows other readers of your code to know that the value can never change, so they don’t need to go hunt for places where it might change its value after its initial declaration.
Use readonly
on class variables whenever possible Similar to const for local/global variables, this information makes it clear that the value can never change, making your code more understandable.
Mark class variables and methods private
(or protected
) whenever possible When you mark variables private
(or protected
), it is an indication that nothing outside of this class (or its subclasses) can alter these values. This makes it easier to understand your code, and prevents certain kinds of bugs due to ‘misuse’ of the internal state of your class by other code.
Use parameter properties on constructor arguments Rather than writing boilerplate code to plumb through an assignment from a constructor argument to a readonly/private/protected member variable, use parameter properties on the constructor.
Do this:
class Foo {
constructor(private bar: string, readonly protected foo: number, public naf: boolean)
}
Not this:
class Foo {
private bar: string;
readonly protected foo: number;
naf: boolean;
constructor(bar: string, foo: number, naf: boolean) {
this.bar = bar;
this.foo = foo;
this.naf = naf;
}
}
Use override
on all class methods that are being overridden, especially start()
and preStart()
If you explicitly mark a method with the override
keyword, the compiler will guarantee that you are actually overriding a function from the base class. This can catch common spelling errors such as typing prestart()
rather than preStart()
.
Use undefined
rather than null
TypeScript, unfortunately, has two values that are ‘nullish’. They both are ‘falsy’, but they are not equal to one another. Try to use undefined
, whenever possible, as the value for things that have no value set on them. The undefined
value is what the TypeScript api returns for things like accessing an unset index on an array, or fetching an unstored key from a Map, or for an optional argument to a method that has not been supplied.
The only time to use null
is when you really need to distinguish between ‘this value has not been set’ (undefined
) and ‘this value has been explicitly set to no value’ (null
).
Unfortunately, Meta, in its infinite wisdom, has chosen to use null
in a bunch of Meta Horizon Worlds apis where they probably should have used undefined
. So, you will need to adapt to that.
Minimize escape characters in strings When writing non-interpolated string values, use the appropriate quotation marks to avoid unnecessary escape characters. I.e. if your string has apostrophes, use double quotes for the string. If your string has embedded double quotes, use an apostrophe for the string.
Do this:
const str1 = 'This string "has quotes" and stuff'
const str2 = "This string 'has apostrophes' and stuff"
Not this:
const str1 = "This string \"has quotes\" and stuff"
const str2 = 'This string \'has apostrophes\' and stuff'
Use interpolated strings only when there is interpolation Don’t use backticks for quoting strings unless you are actually interpolating variable values into the string.
Do this:
const num = 3
const str1 = `This string interpolates ${num} a number`
const str2 = 'This string does not'
Not this:
const str3 = `This string has no interpolation`
Use tuples rather than returning pair objects If you need to return two values from a function, return a tuple [a, b] rather than creating a pair object {first: a, second: b}, and then use destructuring to extract the parts of the tuple at the call site.
Do this:
function splitInHalf(input: string): [string, string] {
... code to split string into x and y
return [x, y]
}
const [left, right] = splitInHalf('my string');
Not this:
type Pair {
first: string;
second: string;
}
function splitInHalf(input: string): Pair {
.. code to split string into x and y
return {first: x, second: y};
}
const value: Pair = splitInHalf('my string);
const left = value.first;
const right = value.second;
Do not assign function declarations to variables or pass them as arguments Just use arrow functions.
Do this:
const myFunction = (arg1: bool) => { ... stuff ... }
Not this:
const myFunction = function(arg1: bool) { ... stuff ...}
Do not assign arrow functions to class member variables Instead, just write a method on the class.
Do this:
class MyClass {
method(arg1: boolean) {
... body ...
}
}
Not this:
class MyClass {
method = (arg1: boolean) => {
... body ...
}
}
Omit obvious types when initializing variables at declaration If you are initializing a variable to a number of boolean or specific class or whatever, you do not need to also put a type on the variable. Also, return types for functions that have obvious outputs can be omitted. Only do this when it is 100% unambiguous, tho. If in doubt, put the type.
Do this:
const foo = 3
function foo() {
return true;
}
Not this:
const foo: number = 3
function foo(): boolean {
return true;
}
For simple arrays of type T
, use T[]
and readonly T[]
rather than Array<T>
or ReadonlyArray<T>
It is easier to read the T[]
form, so prefer that, even for non readonly multidimensional arrays (e.g. T[][][]
). Only revert to the Array<>
form if the type of the array is a compound type itself.
Do this:
let myStringArray: string[];
let myMatrix: number[][];
let myReadonlyMatrix: ReadonlyArray<number[]>;
let myArrayOfStringOrNumber: Array<string | number>;
Not this:
let myStringArray: Array<string>;
let myMatrix: Array<Array<number>>;
let myReadonlyMatrix: readonly number[][];
let myArrayOfStringOrNumber: (string | number)[];
Use forEach()
, map()
, reduce()
, and filter()
instead of writing for()
loops These functional programming constructs are easier to understand and less prone to error than for loops. However, they are somewhat slower, so don’t use them in critical performance areas. However, such critical performance areas are unlikely to exist in most Meta Horizon Worlds creations.
Do this:
myArray.forEach(entry => {
doSomething(entry)
}
const newArray = myArray.map(entry => alterEntry(entry))
const filteredArray = myArray.filter(entry => isWantedEntry(entry))
const sum = myArray.reduce((acc, value) => acc + value, 0)
Not this:
for (let i = 0; i < myArray.length; i++) {
doSomething(myArray[i])
}
const newArray = []
for (let i = 0; i < myArray.length; i++) {
newArray.push(alterEntry(myArray[i]));
}
const filteredArray = []
for (let i = 0; i < myArray.length; i++) {
if (isWantedEntry(myArray[i]) {
filteredArray.push(myArray[i]);
}
}
let sum = 0
for (let i = 0; i < myArray.length; i++) {
sum = sum + myArray[i]
}
Avoid using methods on the Object class If possible, do not use reflection methods like Object.prototype.hasOwnProperty()
, Object.keys()
, Object.values()
, Object.entries()
, etc. These are not strongly typed and can cause runtime errors if you are not absolutely sure what you’re doing.
Prefer the Map<K, V>
type rather than using object
or Record<K, V>
types for key/value mappings The Map<K, V>
type gives you additional functionality over object
types, including the use of any type as a key, the has()
method, the size
property, and the ability to iterate over entries in insertion order using for..of
loops.
A Record<K, V>
is a more strongly typed object
, which can be useful if you have to work with object types rather than Maps for some reason.
Use the spread operator … to concatenate arrays and objects When combining a number of arrays together into a new array, use the ... operator to spread the arrays or objects into a new array/object literal rather than using Array.concat() or Object.assign() to combine them together.
Do this:
View({
style: {
...defaultViewStyle,
margin: 10,
padding: 20,
}
})
Not this:
View({
style: defaultViewStyle.concat({
margin: 10,
padding: 20,
})
})
Export only what’s needed Only export type definitions, classes, and variables that actually need to be imported elsewhere. Default to not exporting things until needed. Only use named exports, do not do default
exports.
Consider destructured imports Rather than doing import * as hz from ‘horizon/core’
, import specific types/classes that you need, i.e. import { Component } from ‘horizon/core’
. Only importing what you need makes it clear to other people what the code in your file is doing, and is slightly better for compiler performance and optimization tools. VSCode will help you auto-manage your destructured import list. If you need to rename something to avoid a collision, you can use as
to rename the imported symbol in your file.
PascalCase for types, camelCase for variables/functions, CAPITALIZED_SNAKE_CASE for global constants Classes and type declarations should use PascalCase (each word capitalized). Variable and function names should use camelCase (first word lower case, remaining words capitalized). Global constants should use CAPITALIZED_SNAKE_CASE (each word capitalized, separated by an underscore). Do not use snake_case mixed with PascalCase, or PascalCase for variables/functions. It should be immediately identifiable whether something is a type or a variable/function based on the naming.
const DEFAULT_POINTS = 3;
type UserData = {
totalPoints: number;
}
class MySpecialClass {
addPoints(userData: UserData, newPoints?: number) {
userData.totalPoints += newPoints ?? DEFAULT_POINTS
}
}
Name your variables, functions, classes, etc descriptively. Avoid single letter or very short variable names in all but the most simplistic cases. You want to write ‘really obvious code’ so that someone else or a later version of you will be able to read it and understand what its doing just by the names of things.
Don’t use _ (underline) prefix for private member variables It’s really not necessary, and makes the code harder to read. Most of your class member variables should be private, anyway.
All imports at top of file Do not intersperse import statements throughout your file. Put them at the top. Also, keep them organized in alphabetical order and remove unnecessary import declarations. The VSCode ‘Organize Imports’ function can do this for you. Run it every so often to clean them up.
Organize files with type definitions first, then globals, then classes. Having a standardized organization for where different sorts of things are in a file will make orienting people to your code easier when they first encounter it. Put type definitions first after imports. Then declare any global constants or variables used in the file. Then declare your classes. Register each of your Components immediately after their declaration.
Organize classes with statics, then variables, then methods. Having a standardized organization for where different things are in your class will make orienting people to your code easier when they first encounter it. Put static variables first (e.g. propsDefinition), followed by any static method. Then put instance variables, and finally put instance methods.
import { Component, PropTypes } from "horizon/core";
type MyData = {
info: string,
price: number,
}
const DEFAULT_PRICE = 10;
class MyComponent extends Component<typeof MyComponent> {
static propsDefinition = {
prop1: { type: PropTypes.String, default: "default value" },
};
static dataMap: Map<string, MyData> = new Map();
static addData(key: string, data: MyData) {
MyComponent.dataMap.set(key, data);
}
readonly myKey = "key";
myData: MyData = { info: "info", price: DEFAULT_PRICE };
override start() {
console.log("MyComponent started");
MyComponent.addData(this.myKey, this.myData);
}
myFunction() {
console.log("MyComponent function called");
}
}
Like using descriptive names, you want to write ‘really obvious code’. Don’t use obscure language features in ‘clever ways’ or try to condense complex code into a small space such that its purpose is inscrutable at first glance. Do not try to ‘show off’ to other people by writing very convoluted code when more straightforward would do. If there are reasons to do something ‘clever’ or use esoteric language features, make sure you add copious comments describing what you are doing and, more importantly, why.
A ‘future you’ will thank yourself if you write down what you were thinking when you wrote your code! However, do not comment pedantically, and make sure you have
used descriptive names for your variables and functions, as that can make extra comments unnecessary. Only comment the
important things, and comment things that the VSCode hover popups will pick up, such as documentation comments on functions describing its effects, parameters, and return values.
When you use the TSDoc comment format, the VSCode editor will be able to pick them up and display them as popups when you hover over the identifiers usage elsewhere. You do not need to include types in your comments, as they are already in the code.
Comments on functions and methods should be of the form:
/**
* Description of what myFunction does
* @param paramName parameter purpose
* @return information about return value
*/
function myFunction(paramName: type): returnValue {
}
On variables or classes, it should just be a block of descriptive text
/**
* Information about myVariable
*/
const myVariable: number = 3
/**
* Information about MyClass
*/
class MyClass {
}
If you are writing code for a bunch of Components that are closely related, sometimes it is useful to put them all in the same file for organizational reasons or so that they can share some types or global variables that don’t need to be exported.
However, avoid writing really long files. Also, do not put unrelated Components in the same file, as it makes code organization less clear, encourages accidental and unnecessary coupling between Components, and reduces reusability of your code.
A line comment starts with two slashes // and continues to the end of the current line. Use these inline in your code to describe interesting details of the algorithm or other important information. Prefer multiple lines of single line comments using // to multi-line block comments using /* */.
Do this:
class MyClass {
someMethod() {
someCode();
// someDisabledCode1();
// someDisabeldCode2();
}
// someDisabledMethod() {
// thing1();
// thing2();
// thing3();
// }
}
Not this:
class MyClass {
someMethod() {
someCode();
/*
someDisabledCode1();
someDisabeldCode2();
*/
}
/*
someDisabledMethod() {
thing1();
thing2();
thing3();
}
*/
}
Coding conventions discussions can be contentious, with ‘religious wars’ about where spaces go, when to use newlines, how many statements can go on a line, etc. One way to avoid those pitfalls is to just leave it to someone else to argue about what is the ‘best’ way to format code, and just accept whatever they decide. Additionally, it requires the expending of extra mental energy to apply the conventions while you are typing your code, so having something do that automatically frees up brain cycles for something else.
Fortunately for us, there is
Prettier, an industry leading coding conventions enforcer plugin that has made the decisions for us, and will apply them automatically. They have a good summary on
why you should use Prettier. In particular, they have an
opinionated philosophy on code formatting, so there are fairly few options to configure how it works. It’s best to just leave their options on their defaults, and accept that they have thought long and hard about
why their way is the best way.
To install prettier in VSCode, simply open VSCode, go to the Extensions tab on the right sidebar (the three cubes with the fourth cube offset), search for “Prettier”, click on “Prettier - Code formatter”, and click Install.
The most important configuration setting to make is to ensure that Prettier is used as the default formatter for typescript files. If you don’t currently have a formatter configured, and you are in a typescript file, and run Format Document (Shift + Alt + F), you will get a popup asking you to Configure a default formatter. Make sure you choose “Prettier - Code Formatter”.
If you don’t get a popup when doing Format Document for the first time, it means you already have a typescript formatter configured. You will want to change that to Prettier by going to File -> Preferences -> Settings, typing @id:editor.defaultFormatter @lang:typescript
in the search bar, and switching the Default Formatter to ‘Prettier - Code Formatter’.
By default, Prettier won’t format anything until you tell it to via Format Document or Format Selection. However, you can also configure VSCode to Format On Save, On Paste, and On Type. Note that if you have File: Auto Save set to afterDelay
, then Format On Save will only work on an explicit save.
To find the formatting options in File->Preferences->Settings, search for editor:format
and tick on whichever options you prefer (below shows all of On Paste, On Save, and On Type enabled).
There are a number of other minor settings you can adjust, listed on the
options page and via the ‘gear’ icon on the plugin, such as Tab Width (default 2), and Print Width (default 80). Those values are a bit of an anachronism from when people didn’t have very wide monitors. However, if you make customizations, make sure you put a
configuration file like
.prettierrc
or
.editorconfig
in the
scripts
directory of
each of your horizon world projects so that others using your code will pick up the same settings. Thus, it’s best to just not change them at all.
If there are particular files that you need Prettier to ignore, you can add them to the
.prettierignore
file (in the
same format as .gitignore).
If you have precious but non-standard formatting somewhere in your file that you want to preserve, you can precede that block of code with a comment of the following form:
This will prevent Prettier from reformatting until the syntactically next statement is reached (which can be one line or many lines, depending on the type of statement that follows the comment).
There are a variety of language features that are often misused, or just bad to use at all. These are more complex ‘code quality’ issues rather than formatting conventions handled by plugins like Prettier. The class of plugins that check for these code quality issues are typically called ‘linters’, as they remove unwanted bits of fluff from your code (i.e. the ‘lint’). Linters are very powerful tools and often have a bewildering number of rules that can be configured. This is a more advanced helper than coding convention formatters like Prettier, but will identify subtle programming errors in your code and make your code more robust and bug free. Reading the web pages associated with any linter errors found in your code will teach you how to be a better typescript coder.
The ESlint linter has been used for years to check Javascript/ECMAscript code, and now has a variant called
typescript-eslint specifically tailored for Typescript that uses typing information provided by the typescript compiler to enhance its ability to detect coding errors. (note: there is an
older TSLint project that has been deprecated. Use typescript-eslint instead). (Also, note that while ESLint can do coding convention formatting as well, they
recommend leaving that to Prettier).
Installation of typescript-eslint is significantly more complicated than installation of Prettier, which is just a VSCode plugin. Installing the ESLint plugin within VSCode is part of the process, but you will need to also have a minimal Node.js installation, and use npm (or yarn or pnpm) to locally install and configure ESLint into each project you want to use it in using command line tools. You will need to know the directory of your projects scripts directory, which you can easily get from both the Desktop Editor and VSCode.
- Desktop Editor - Open Scripts Folder in Explorer
Go to the scripts menu, press on the triple dot menu, and select Open Scripts Folder in Explorer (note it actually opens the folder above the scripts folder, you will need to click on scripts
once the window opens to go into that folder)
- VS Code - Reveal in File Explorer
Right click on your tsconfig.json
file and select Reveal in File Explorer
In the File Explorer, you can then click past the end of the location bar text (don’t click on any of the text), and copy the path to the scripts
directory to the clipboard for use with your command line terminal of choice.
It should be something like C:\Users\live\AppData\LocalLow\Meta\Horizon Worlds\eslint test_10160832935606146\scripts
VSCode ESLint plugin (one time) To install ESLint in VSCode, open VSCode, go to the Extensions tab on the right sidebar (the three cubes with the fourth cube offset), search for “ESLint”, and click Install (same procedure as for Prettier).
Node.js runtime (one time) Download the latest Node.js LTS package from
https://nodejs.org/en/ (current version is v22.11.0) and install. Use all the default installation options (don’t enable or disable any options).
Open a Terminal prompt (e.g. run ‘cmd’ from the system search bar), change directory to the scripts
directory of your world you obtained above, e.g.
cd C:\Users\live\AppData\LocalLow\Meta\Horizon Worlds\eslint test_10160832935606146\scripts
and run:
npm install --no-save eslint@^8.56.0 @eslint/js typescript@4.7.4 typescript-eslint@^7.18.0 eslint-config-prettier
(note, if you are using the yarn or pnpm package managers, see instructions
here).
(note, we need to pin to older versions of eslint and typescript-eslint because horizon worlds is running such an old version of typescript).
(note, due to current shortcomings of the desktop editor, you must run npm install
after each time you open the desktop editor on the world, as the desktop editor will clobber the previous installation of eslint).
ESLint configuration file (every world) Create a eslint.config.mjs
file in your world’s scripts
directory. Be sure to exclude the types/*.d.ts
files, as the code there is not well formatted and will cause lots of error messages, and the .backups
directory that contains old deleted code.
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
import eslintConfigPrettier from 'eslint-config-prettier';
export default tseslint.config(
eslint.configs.recommended,
// @ts-ignore
tseslint.configs.recommended,
eslintConfigPrettier,
{
ignores: [
'types/*.d.ts',
'.backups/**',
]
},
);
Installation Test (every world… tho can skip once you have the process sorted out) Create a file in your scripts
directory (e.g. ESLintTest.ts
) with valid typescript, but an obviously prohibited language construct, such as the following variable declaration that uses the any
type.
export const foo: any = true;
Test that you are able to run eslint from the command line from your scripts directory, and that it detects this error
This command should generate an error message on the above file:
C:\Users\name\AppData\LocalLow\Meta\Horizon Worlds\eslint test_10160832935606146\scripts\ESLintTest.ts
1:19 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
✖ 1 problem (1 error, 0 warnings)
Next, open VSCode on your same project, and click the same test file (e.g. ESLintTest.ts
) and verify that the “Problems” tab of the IDE shows the same error message for this file.
If you click on the underlined error message in the Problems listing, a web browser will be launched that explains why what you have written is a bad idea (in this case linking to
@typescirpt-eslint/no-explicit-any).
If you hover over the error in the code and click Ctrl-
. (for Quick Fix), you will see that the first suggestion in the pop-up is to ‘use unknown
instead’, which is a much better option than any
.
- recommendedTypeChecked
- Uses the power of the typescript typing engine to do even deeper checks on your code usage
- Requires a minor amount of extra configuration
- strict
- Includes everything in ‘recommended’, plus somewhat more ‘opinionated’ rules that may not be applicable to all codebases
- Also comes in a strictTypeChecked variant with extra typing checks
- stylistic
- Additional opinionated stylistic rules that don’t impact program logic to be used alongside either ‘recommended’ or ‘strict’
- Also comes in a stylisticTypeChecked variant with extra typing checks
The full list of all supported rules can be found here:
The ESLint plugin to VSCode also has configuration options as well. By default, the linter runs as you type, which is usually the best option, as you want to know as soon as possible when there is a coding problem. But, you can change that to only run on save, if you so desire.
If you find that some linter error is incorrect or too aggressive for your code, you can insert a comment above the line causing the problem to tell the linter to ignore it. The Quick Fix in VSCode can help you format these comments, but they are generally of the form:
/* eslint-disable-disable-next-line <rule-name-here> */
Where <rule-name-here>
is the name of the eslint rule that is reporting an error. You can disable eslint errors or the entire file using the following comment at the top of the file.
/* eslint-disable <rule-name-here> */
You really should endeavor never to disable the alerts, and instead fix your code so that it compiles with no warnings or errors.
When there are misspelled words in your code, it does not only cause confusion for others reading your code and trying to understand what you were trying to accomplish. Sometimes, there are certain coding errors that can’t be caught by linters that can be caught simply by matching spelling with well designed API definitions (i.e. ones that follow good spelling rules as well).
This popular plugin checks your code and comments for spelling mistakes. It is easy to add new words to a personal dictionary should you have some particular jargon that doesn’t appear in the standard dictionary.
To install Code Spell Checker in VSCode, simply open VSCode, go to the Extensions tab on the right sidebar (the three cubes with the fourth cube offset), search for “spell check”, click on “Code Spell Check”, and click Install.
There is not a lot of additional configuration need, but there are a few additional options if you check the plugin settings page. It will highlight spelling errors in your code as ‘warnings’ of ‘Unknown words’in the ‘Problems’ view of VSCode. You can use ‘Quick Fix’ (Ctrl-
.) to get spelling suggestions, and if you want to add a word permanently to the dictionary, you can select ‘Add Word to User Settings’ (note, don’t add to Workspace Settings, as the Horizon Desktop Editor will overwrite that file and delete them). You can also access the Spelling settings by right clicking in the editor window and selecting the ‘Spelling’ sub-menu.
The typescript-eslint documentation explains how to
enable type checked linting.In particular, you will need to use a TypeChecked version of one of the shared configs, and you need to configure ESLint to use the typescript parser on the current directory.
Additionally, though it will work without changes, to avoid errors in VSCode, we can’t use import.meta.path
, but must use a legacy Node.js api instead. And we must prevent the typing pass from including the ESLint configuration file, as it is not part of our typescript project.
Change your eslint.config.mjs
to the following:
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
import eslintConfigPrettier from 'eslint-config-prettier';
export default tseslint.config(
eslint.configs.recommended,
tseslint.configs.recommendedTypeChecked,
eslintConfigPrettier,
{
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: dirname(fileURLToPath(import.meta.url),
},
},
},
{
ignores: [
'types/*.d.ts',
'.backups/**',
'eslint.config.mjs'
]
},
);
Other Typescript Coding Conventions
TS.dev
More or less the same as the google conventions but in an easier to navigate format.
https://ts.dev/style/Angular
An opinionated set of conventions targeting angular developers. Some good ideas, but some parts not applicable to Meta Horizon Worlds
https://angular.dev/style-guide