The following guide tries to highlight some of the key differences between C#, Javascript and Typescript. This is most useful for developers new to the web ecosystem.
Here are also some useful resources for learning how to write Typescript:
Key differences between C#, Javascript or Typescript
CSharp or C# is a statically typed & compiled language. It means that before your code can run (or be executed) it has to be compiled - translated - into IL or CIL, an intermediate language that is a little closer to machine code. The important bit to understand here is that your code is analyzed and has to pass certain checks and rules that are enforced by the compiler. You will get compiler errors in Unity and your application not even start running if you write code that violates any of the rules of the C# language. You will not be able to enter Play-Mode with compiler errors.
Javascript on the other hand is interpreted at runtime. That means you can write code that is not valid and cause errors - but you will not see those errors until your program runs or tries to execute exactly that line that has the error. For example you can write var points = 100; points += "hello world";
and nobody will complain until you run the code in a browser.
Typescript is a language designed by Microsoft that compiles to javascript
It adds a lot of features like for example type-safety. That means when you write code in Typescript you can declare types and hence get errors at compile-time when you try to e.g. make invalid assignments or call methods with unexpected types. Read more about types in Javascript and Typescript below.
Types — or the lack thereof
Vanilla Javascript does (as of today) not have any concept of types: there is no guarantuee that a variable that you declared as let points = 100
will still be a number later in your application. That means that in Javascript it is perfectly valid code to assign points = new Vector3(100, 0, 0);
later in your code. Or even points = null
or points = myRandomObject
- you get the idea. This is all OK while you write the code but it may crash horrible when your code is executed because later you write points -= 1
and now you get errors in the browser when your application is already running.
As mentioned above Typescript was created to help fix that problem by adding syntax for defining types.
It is important to understand that you basically still write Javascript when you write Typescript and while it is possible to circumvent all type checking and safety checks by e.g. adding //@ts-ignore
above a erroneous line or defining all types as any
this is definitely not recommneded. Types are here to help you find errors before they actually happen. You really dont want to deploy your website to your server only to later get reports from users or visitors telling you your app crashed while it was running.
While vanilla Javascript does not offer types you can still add type-annotations to your javascript variables, classes and methods by using JSDoc.
Variables
In C# you write variables either by using the type or the var
keyword.
For example you can either write int points = 100;
or alternatively use var
and let the compiler figure out the correct type for you: var points = 100
In Javascript or Typescript you have two modern options to declaring a variable.
For a variable that you plan to re-assign use let
, for example let points = 100;
For a variable that you do not want to be able to re-assign use const
, for example const points = 100;
Be aware of var
You might come across thevar
keyword in javascript as well but it is not recommended to use it and the modern replacement for it islet
. Learn more about var vs let.
Please note that you can still assign values to variables declared with const if they are (for example) a custom type. Consider the following example:
const const myPosition: Vector3
myPosition : class Vector3
Vector3 = new new Vector3(x?: number | undefined, y?: number | undefined, z?: number | undefined): Vector3
Vector3(0, 0, 0);
const myPosition: Vector3
myPosition.Vector3.x: number
x = 100; // Assigning x is perfectly fine
The above is perfectly fine Typescript code because you don't re-assign myPosition
but only the x
member of myPosition
. On the other hand the following example would not be allowed and cause a runtime or typescript error:
const const myPosition: Vector3
myPosition : class Vector3
Vector3 = new new Vector3(x?: number | undefined, y?: number | undefined, z?: number | undefined): Vector3
Vector3(0, 0, 0);
myPosition = new new Vector3(x?: number | undefined, y?: number | undefined, z?: number | undefined): Vector3
Vector3(100, 0, 0); // âš ASSIGNING TO CONST IS NOT ALLOWED
Using or Importing Types
In Unity you usually add using
statements at the top of you code to import specific namespaces from Assemblies that are references in your project or - in certain cases - you migth find yourself importing a specific type with a name from a namespace.
See the following example:
using UnityEngine;
// importing just a specific type and giving it a name
using MonoBehaviour = UnityEngine.MonoBehaviour;
This is how you do the same in Typescript to import specific types from a package:
import { class Vector3
Vector3 } from 'three';
import { class Behaviour
Needle Engine component base class. Component's are the main building blocks of the Needle Engine.
Derive from
Behaviour
to implement your own using the provided lifecycle methods.
Components can be added to threejs objects using [addComponent](addComponent)
or [GameObject.addComponent](GameObject.addComponent)
The most common lifecycle methods are awake
, start
, onEnable
, onDisable
update
and onDestroy
.
XR specific callbacks include onEnterXR
, onLeaveXR
, onUpdateXR
, onControllerAdded
and onControllerRemoved
.
To receive pointer events implement onPointerDown
, onPointerUp
, onPointerEnter
, onPointerExit
and onPointerMove
.
Behaviour } from '@needle-tools/engine';
You can also import all the types from a specific package by giving it a name which you might see here and there:
import * as import THREE
THREE from 'three';
const const myVector: THREE.Vector3
myVector : import THREE
THREE.class Vector3
Vector3 = new import THREE
THREE.constructor Vector3(x?: number | undefined, y?: number | undefined, z?: number | undefined): THREE.Vector3
Vector3(1, 2, 3);
Primitive Types
Vector2, Vector3, Vector4...
If you have a C# background you might be familiar with the difference between a class and a struct. While a class is a reference type a struct is a custom value type. Meaning it is, depending on the context, allocated on the stack and when being passed to a method by default a copy is created.
Consider the following example in C#:
void MyCallerMethod(){
var position = new Vector3(0,0,0);
MyExampleVectorMethod(position);
UnityEngine.Debug.Log("Position.x is " + position.x); // Here x will be 0
}
void MyExampleVectorMethod(Vector3 position){
position.x = 42;
}
A method is called with a Vector3 named position. Inside the method the passed in vector position
is modified: x is set to 42. But in C# the original vector that is being passed into this method (see line 2) is not changed and x will still be 0 (line 4).
The same is not true for Javascript/Typescript. Here we don't have custom value types, meaning if you come across a Vector in Needle Engine or three.js you will always have a reference type.
Consider the following example in typescript:
import { class Vector3
Vector3 } from 'three'
function function myCallerMethod(): void
myCallerMethod() : void {
const const position: Vector3
position = new new Vector3(x?: number | undefined, y?: number | undefined, z?: number | undefined): Vector3
Vector3(0,0,0);
function myExampleVectorMethod(position: Vector3): void
myExampleVectorMethod(const position: Vector3
position);
var console: Console
The console
module provides a simple debugging console that is similar to the
JavaScript console mechanism provided by web browsers.
The module exports two specific components:
- A
Console
class with methods such as console.log()
, console.error()
and console.warn()
that can be used to write to any Node.js stream.
- A global
console
instance configured to write to process.stdout
and
process.stderr
. The global console
can be used without calling require('console')
.
Warning: The global console object's methods are neither consistently
synchronous like the browser APIs they resemble, nor are they consistently
asynchronous like all other Node.js streams. See the note on process I/O
for
more information.
Example using the global console
:
console.log('hello world');
// Prints: hello world, to stdout
console.log('hello %s', 'world');
// Prints: hello world, to stdout
console.error(new Error('Whoops, something bad happened'));
// Prints error message and stack trace to stderr:
// Error: Whoops, something bad happened
// at [eval]:5:15
// at Script.runInThisContext (node:vm:132:18)
// at Object.runInThisContext (node:vm:309:38)
// at node:internal/process/execution:77:19
// at [eval]-wrapper:6:22
// at evalScript (node:internal/process/execution:76:60)
// at node:internal/main/eval_string:23:3
const name = 'Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr
Example using the Console
class:
const out = getStreamSomehow();
const err = getStreamSomehow();
const myConsole = new console.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(new Error('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
const name = 'Will Robinson';
myConsole.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to err
console.Console.log(message?: any, ...optionalParams: any[]): void (+1 overload)
Prints to stdout
with newline. Multiple arguments can be passed, with the
first used as the primary message and all additional used as substitution
values similar to printf(3)
(the arguments are all passed to util.format()
).
const count = 5;
console.log('count: %d', count);
// Prints: count: 5, to stdout
console.log('count:', count);
// Prints: count: 5, to stdout
See util.format()
for more information.
log("Position.x is " + const position: Vector3
position.Vector3.x: number
x); // Here x will be 42
}
function function myExampleVectorMethod(position: Vector3): void
myExampleVectorMethod(position: Vector3
position: class Vector3
Vector3) : void {
position: Vector3
position.Vector3.x: number
x = 42;
}
Do you see the difference? Because vectors and all custom objects are in fact reference types we will have modified the original position
variable (line 3) and x is now 42.
This is not only important to understand for methods but also when working with variables.
In C# the following code will produce two instances of Vector3 and changing one will not affect the other:
var myVector = new Vector3(1,1,1);
var myOtherVector = myVector;
myOtherVector.x = 42;
// will log: 1, 42
UnityEngine.Debug.Log(myVector.x + ", " + myOtherVector.x);
If you do the same in Typescript you will not create a copy but get a reference to the same myVector
instance instead:
import { class Vector3
Vector3 } from 'three'
const const myVector: Vector3
myVector = new new Vector3(x?: number | undefined, y?: number | undefined, z?: number | undefined): Vector3
Vector3(1,1,1);
const const myOtherVector: Vector3
myOtherVector = const myVector: Vector3
myVector;
const myOtherVector: Vector3
myOtherVector.Vector3.x: number
x = 42;
// will log: 42, 42
var console: Console
The console
module provides a simple debugging console that is similar to the
JavaScript console mechanism provided by web browsers.
The module exports two specific components:
- A
Console
class with methods such as console.log()
, console.error()
and console.warn()
that can be used to write to any Node.js stream.
- A global
console
instance configured to write to process.stdout
and
process.stderr
. The global console
can be used without calling require('console')
.
Warning: The global console object's methods are neither consistently
synchronous like the browser APIs they resemble, nor are they consistently
asynchronous like all other Node.js streams. See the note on process I/O
for
more information.
Example using the global console
:
console.log('hello world');
// Prints: hello world, to stdout
console.log('hello %s', 'world');
// Prints: hello world, to stdout
console.error(new Error('Whoops, something bad happened'));
// Prints error message and stack trace to stderr:
// Error: Whoops, something bad happened
// at [eval]:5:15
// at Script.runInThisContext (node:vm:132:18)
// at Object.runInThisContext (node:vm:309:38)
// at node:internal/process/execution:77:19
// at [eval]-wrapper:6:22
// at evalScript (node:internal/process/execution:76:60)
// at node:internal/main/eval_string:23:3
const name = 'Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr
Example using the Console
class:
const out = getStreamSomehow();
const err = getStreamSomehow();
const myConsole = new console.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(new Error('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
const name = 'Will Robinson';
myConsole.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to err
console.Console.log(message?: any, ...optionalParams: any[]): void (+1 overload)
Prints to stdout
with newline. Multiple arguments can be passed, with the
first used as the primary message and all additional used as substitution
values similar to printf(3)
(the arguments are all passed to util.format()
).
const count = 5;
console.log('count: %d', count);
// Prints: count: 5, to stdout
console.log('count:', count);
// Prints: count: 5, to stdout
See util.format()
for more information.
log(const myVector: Vector3
myVector.Vector3.x: number
x, const myOtherVector: Vector3
myOtherVector.Vector3.x: number
x);
Vector Maths and Operators
While in C# you can use operator overloading this is not available in Javascript unfortunately. This means that while you can multiply a Vector3 in C# like this:
var myFirstVector = new Vector3(1,1,1);
var myFactor = 100f;
myFirstVector *= myFactor;
// → myFirstVector is now 100, 100, 100
you have to use a method on the Vector3 type to archieve the same result (just with a little more boilerplate code)
import { class Vector3
Vector3 } from "three"
const const myFirstVector: Vector3
myFirstVector : class Vector3
Vector3 = new new Vector3(x?: number | undefined, y?: number | undefined, z?: number | undefined): Vector3
Vector3(1, 1, 1)
const const myFactor: 100
myFactor = 100;
const myFirstVector: Vector3
myFirstVector.Vector3.multiplyScalar(s: number): Vector3
Multiplies this vector by scalar s.
multiplyScalar(const myFactor: 100
myFactor);
// → myFirstVector is now 100, 100, 100
Equality Checks
loose vs strict comparison
In C# when you want to check if two variables are the same you can write it as follows:
var playerIsNull = myPlayer == null;
in Javascript/Typescript there is a difference between ==
and ===
where ===
is more strictly checking for the type:
const const playerIsNull: boolean
playerIsNull = const myPlayer: any
myPlayer === null;
const const playerIsNullOrUndefined: boolean
playerIsNullOrUndefined = const myPlayer: any
myPlayer == null;
You notice that the second variable playerIsNullOrUndefined
is using ==
which does a loose equality check in which case null
and undefined
will both result in true
here. You can read more about that here
Events, Binding and this
When you subscribe to an Event in C# you do it like this:
// this is how an event is declared
event Action MyEvent;
// you subscribe by adding to (or removing from)
void OnEnable() {
MyEvent += OnMyEvent;
}
void OnDisable() {
MyEvent -= OnMyEvent;
}
void OnMyEvent() {}
In Typescript and Javascript when you add a method to a list you have to "bind this". That essentially means you create a method where you explictly set this
to (usually) your current class instance. There are two way to archieve this.
Please note that we are using the type EventList
here which is a Needle Engine type to declare events (the EventList will also automatically be converted to a UnityEvent and or a event list in Blender when you use them with our Editor integrations)
The short and recommended syntax for doing this is to use Arrow Functions.
import { class EventList
The EventList is a class that can be used to create a list of event listeners that can be invoked
EventList, class Behaviour
Needle Engine component base class. Component's are the main building blocks of the Needle Engine.
Derive from
Behaviour
to implement your own using the provided lifecycle methods.
Components can be added to threejs objects using [addComponent](addComponent)
or [GameObject.addComponent](GameObject.addComponent)
The most common lifecycle methods are awake
, start
, onEnable
, onDisable
update
and onDestroy
.
XR specific callbacks include onEnterXR
, onLeaveXR
, onUpdateXR
, onControllerAdded
and onControllerRemoved
.
To receive pointer events implement onPointerDown
, onPointerUp
, onPointerEnter
, onPointerExit
and onPointerMove
.
Behaviour, const serializable: <T>(type?: Constructor<T> | TypeResolver<T> | (TypeResolver<T> | Constructor<any>)[] | null | undefined) => (_target: any, _propertyKey: string | {
name: string;
}) => void
The serializable attribute should be used to annotate all serialized fields / fields and members that should be serialized and exposed in an editor
serializable } from "@needle-tools/engine";
export class class MyComponent
MyComponent extends class Behaviour
Needle Engine component base class. Component's are the main building blocks of the Needle Engine.
Derive from
Behaviour
to implement your own using the provided lifecycle methods.
Components can be added to threejs objects using [addComponent](addComponent)
or [GameObject.addComponent](GameObject.addComponent)
The most common lifecycle methods are awake
, start
, onEnable
, onDisable
update
and onDestroy
.
XR specific callbacks include onEnterXR
, onLeaveXR
, onUpdateXR
, onControllerAdded
and onControllerRemoved
.
To receive pointer events implement onPointerDown
, onPointerUp
, onPointerEnter
, onPointerExit
and onPointerMove
.
Behaviour {
@serializable<EventList>(type?: Constructor<EventList> | TypeResolver<EventList> | (Constructor<any> | TypeResolver<EventList>)[] | null | undefined): (_target: any, _propertyKey: string | {
...;
}) => void
The serializable attribute should be used to annotate all serialized fields / fields and members that should be serialized and exposed in an editor
serializable(class EventList
The EventList is a class that can be used to create a list of event listeners that can be invoked
EventList)
MyComponent.myEvent: EventList
myEvent!: class EventList
The EventList is a class that can be used to create a list of event listeners that can be invoked
EventList;
MyComponent.onEnable(): void
called every time when the component gets enabled (this is invoked after awake and before start)
or when it becomes active in the hierarchy (e.g. if a parent object or this.gameObject gets set to visible)
onEnable() {
this.MyComponent.myEvent: EventList
myEvent.EventList.addEventListener(cb: Function): Function
Add a new event listener to this event
addEventListener(this.MyComponent.onMyEvent: () => void
onMyEvent);
}
MyComponent.onDisable(): void
called every time the component gets disabled or if a parent object (or this.gameObject) gets set to invisible
onDisable() {
this.MyComponent.myEvent: EventList
myEvent.EventList.removeEventListener(cb: Function | null | undefined): void
removeEventListener(this.MyComponent.onMyEvent: () => void
onMyEvent);
}
// Declaring the function as an arrow function to automatically bind `this`
private MyComponent.onMyEvent: () => void
onMyEvent = () => {
var console: Console
The console
module provides a simple debugging console that is similar to the
JavaScript console mechanism provided by web browsers.
The module exports two specific components:
- A
Console
class with methods such as console.log()
, console.error()
and console.warn()
that can be used to write to any Node.js stream.
- A global
console
instance configured to write to process.stdout
and
process.stderr
. The global console
can be used without calling require('console')
.
Warning: The global console object's methods are neither consistently
synchronous like the browser APIs they resemble, nor are they consistently
asynchronous like all other Node.js streams. See the note on process I/O
for
more information.
Example using the global console
:
console.log('hello world');
// Prints: hello world, to stdout
console.log('hello %s', 'world');
// Prints: hello world, to stdout
console.error(new Error('Whoops, something bad happened'));
// Prints error message and stack trace to stderr:
// Error: Whoops, something bad happened
// at [eval]:5:15
// at Script.runInThisContext (node:vm:132:18)
// at Object.runInThisContext (node:vm:309:38)
// at node:internal/process/execution:77:19
// at [eval]-wrapper:6:22
// at evalScript (node:internal/process/execution:76:60)
// at node:internal/main/eval_string:23:3
const name = 'Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr
Example using the Console
class:
const out = getStreamSomehow();
const err = getStreamSomehow();
const myConsole = new console.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(new Error('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
const name = 'Will Robinson';
myConsole.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to err
console.Console.log(message?: any, ...optionalParams: any[]): void (+1 overload)
Prints to stdout
with newline. Multiple arguments can be passed, with the
first used as the primary message and all additional used as substitution
values similar to printf(3)
(the arguments are all passed to util.format()
).
const count = 5;
console.log('count: %d', count);
// Prints: count: 5, to stdout
console.log('count:', count);
// Prints: count: 5, to stdout
See util.format()
for more information.
log(this !== var undefined
undefined, this)
}
}
There is also the more verbose "classical" way to archieve the same thing by manually binding this (and saving the method in a variable to later remove it again from the event list):
import { class EventList
The EventList is a class that can be used to create a list of event listeners that can be invoked
EventList, class Behaviour
Needle Engine component base class. Component's are the main building blocks of the Needle Engine.
Derive from
Behaviour
to implement your own using the provided lifecycle methods.
Components can be added to threejs objects using [addComponent](addComponent)
or [GameObject.addComponent](GameObject.addComponent)
The most common lifecycle methods are awake
, start
, onEnable
, onDisable
update
and onDestroy
.
XR specific callbacks include onEnterXR
, onLeaveXR
, onUpdateXR
, onControllerAdded
and onControllerRemoved
.
To receive pointer events implement onPointerDown
, onPointerUp
, onPointerEnter
, onPointerExit
and onPointerMove
.
Behaviour, const serializable: <T>(type?: Constructor<T> | TypeResolver<T> | (TypeResolver<T> | Constructor<any>)[] | null | undefined) => (_target: any, _propertyKey: string | {
name: string;
}) => void
The serializable attribute should be used to annotate all serialized fields / fields and members that should be serialized and exposed in an editor
serializable } from "@needle-tools/engine";
export class class MyComponent
MyComponent extends class Behaviour
Needle Engine component base class. Component's are the main building blocks of the Needle Engine.
Derive from
Behaviour
to implement your own using the provided lifecycle methods.
Components can be added to threejs objects using [addComponent](addComponent)
or [GameObject.addComponent](GameObject.addComponent)
The most common lifecycle methods are awake
, start
, onEnable
, onDisable
update
and onDestroy
.
XR specific callbacks include onEnterXR
, onLeaveXR
, onUpdateXR
, onControllerAdded
and onControllerRemoved
.
To receive pointer events implement onPointerDown
, onPointerUp
, onPointerEnter
, onPointerExit
and onPointerMove
.
Behaviour {
@serializable<EventList>(type?: Constructor<EventList> | TypeResolver<EventList> | (Constructor<any> | TypeResolver<EventList>)[] | null | undefined): (_target: any, _propertyKey: string | {
...;
}) => void
The serializable attribute should be used to annotate all serialized fields / fields and members that should be serialized and exposed in an editor
serializable(class EventList
The EventList is a class that can be used to create a list of event listeners that can be invoked
EventList)
MyComponent.myEvent?: EventList | undefined
myEvent?: class EventList
The EventList is a class that can be used to create a list of event listeners that can be invoked
EventList;
private MyComponent._onMyEventFn?: Function | undefined
_onMyEventFn?: Function;
MyComponent.onEnable(): void
called every time when the component gets enabled (this is invoked after awake and before start)
or when it becomes active in the hierarchy (e.g. if a parent object or this.gameObject gets set to visible)
onEnable() {
// bind this
this.MyComponent._onMyEventFn?: Function | undefined
_onMyEventFn = this.MyComponent.onMyEvent: () => void
onMyEvent.CallableFunction.bind<() => void>(this: () => void, thisArg: unknown): () => void (+1 overload)
For a given function, creates a bound function that has the same body as the original function.
The this object of the bound function is associated with the specified object, and has the specified initial parameters.
bind(this);
// add the bound method to the event
this.MyComponent.myEvent?: EventList | undefined
myEvent?.EventList.addEventListener(cb: Function): Function
Add a new event listener to this event
addEventListener(this.MyComponent._onMyEventFn?: Function
_onMyEventFn);
}
MyComponent.onDisable(): void
called every time the component gets disabled or if a parent object (or this.gameObject) gets set to invisible
onDisable() {
this.MyComponent.myEvent?: EventList | undefined
myEvent?.EventList.removeEventListener(cb: Function | null | undefined): void
removeEventListener(this.MyComponent._onMyEventFn?: Function | undefined
_onMyEventFn);
}
// Declaring the function as an arrow function to automatically bind `this`
private MyComponent.onMyEvent: () => void
onMyEvent = () => { }
}