TypeScript
This guide explains how to use Skir in a TypeScript/JavaScript project. Generated code can run on Node, Deno or in the browser.
Set up
In your skir.yml file, add the following snippet under generators:
- mod: skir-typescript-gen
outDir: ./skirout
config: {}For more information, see this TypeScript project example.
Generated code guide
The examples below are for the code generated from this .skir file.
Referring to generated symbols
import { TARZAN, SubscriptionStatus, User, UserHistory, UserRegistry } from "../skirout/user";Struct classes
For every struct S in the .skir file, skir generates a frozen (deeply immutable) class S and a mutable class S.Mutable.
Frozen struct classes
// Construct a frozen User with User.create({...})
const john = User.create({
userId: 42,
name: "John Doe",
quote: "Coffee is just a socially acceptable form of rage.",
pets: [
{
name: "Dumbo",
heightInMeters: 1.0,
picture: "🐘",
},
],
subscriptionStatus: "FREE",
// foo: "bar",
// ^ Does not compile: 'foo' is not a field of User
});
assert(john.name === "John Doe");
// john.name = "John Smith";
// ^ Does not compile: all the properties are read-only
// With create<"partial">({...}), you don't need to specify all the fields of
// the struct.
const jane = User.create<"partial">({
userId: 43,
name: "Jane Doe",
pets: [{ name: "Fluffy" }, { name: "Fido" }],
});
// Missing fields are initialized to their default values.
assert(jane.quote === "");
const janeHistory = UserHistory.create({
user: jane,
// ^ the object you pass to create({...}) can contain struct values
sessions: [
{
login: Timestamp.fromUnixMillis(1234),
logout: Timestamp.fromUnixMillis(2345),
},
],
});
const defaultUser = User.DEFAULT;
assert(defaultUser.name === "");
// User.DEFAULT is same as User.create<"partial">({});Mutable struct classes
// User.Mutable is a mutable version of User.
const lylaMut = new User.Mutable();
lylaMut.userId = 44;
lylaMut.name = "Lyla Doe";
// The User.Mutable() constructor also accepts an initializer object.
const jolyMut = new User.Mutable({ userId: 45 });
jolyMut.name = "Joly Doe";
// jolyHistoryMut.user.quote = "I am Joly.";
// ^ Does not compile: quote is readonly because jolyHistoryMut.user might be
// a frozen struct
const jolyHistoryMut = new UserHistory.Mutable();
jolyHistoryMut.user = jolyMut;
// ^ The right-hand side of the assignment can be either frozen or mutable.
// The mutableUser() getter first checks if 'user' is already a mutable struct,
// and if so, returns it. Otherwise, it assigns to 'user' a mutable shallow copy
// of itself and returns it.
jolyHistoryMut.mutableUser.quote = "I am Joly.";
// Similarly, mutablePets() first checks if 'pets' is already a mutable array,
// and if so, returns it. Otherwise, it assigns to 'pets' a mutable shallow copy
// of itself and returns it.
lylaMut.mutablePets.push(User.Pet.create<"partial">({ name: "Cupcake" }));
lylaMut.mutablePets.push(new User.Pet.Mutable({ name: "Simba" }));Converting between frozen and mutable
// toMutable() does a shallow copy of the frozen struct, so it's cheap. All the
// properties of the copy hold a frozen value.
const evilJaneMut = jane.toMutable();
evilJaneMut.name = "Evil Jane";
// toFrozen() recursively copies the mutable values held by properties of the
// object. It's cheap if all the values are frozen, like in this example.
const evilJane: User = evilJaneMut.toFrozen();Writing logic agnostic of mutability
// 'User.OrMutable' is a type alias for 'User | User.Mutable'.
function greet(user: User.OrMutable) {
console.log(`Hello, ${user.name}`);
}
greet(jane);
// Hello, Jane Doe
greet(lylaMut);
// Hello, Lyla DoeEnum classes
The definition of the SubscriptionStatus enum in the .skir file is:
enum SubscriptionStatus {
FREE;
trial: Trial;
PREMIUM;
}Making enum values
const johnStatus = SubscriptionStatus.FREE;
const janeStatus = SubscriptionStatus.PREMIUM;
const lylaStatus = SubscriptionStatus.create("PREMIUM");
// ^ same as SubscriptionStatus.PREMIUM
const jolyStatus = SubscriptionStatus.UNKNOWN;
// Use create({kind: ..., value: ...}) for wrapper variants.
const roniStatus = SubscriptionStatus.create({
kind: "trial",
value: {
startTime: Timestamp.fromUnixMillis(1234),
},
});Conditions on enums
// Use 'union.kind' to check which variant the enum value holds.
assert(johnStatus.union.kind === "FREE");
assert(jolyStatus.union.kind === "UNKNOWN");
assert(roniStatus.union.kind === "trial");
// If the enum holds a wrapper variant, you can access the wrapped value through
// 'union.value'.
assert(roniStatus.union.value.startTime.unixMillis === 1234);
function getSubscriptionInfoText(status: SubscriptionStatus): string {
// Pattern matching on enum variants
switch (status.union.kind) {
case "UNKNOWN":
return "Unknown subscription status";
case "FREE":
return "Free user";
case "PREMIUM":
return "Premium user";
case "trial":
// Here the compiler knows that the type of union.value is
// SubscriptionStatus.Trial
return "On trial since " + status.union.value.startTime;
}
}Serialization
Every frozen struct class and enum class has a static readonly serializer property which can be used for serializing and deserializing instances of the class.
const serializer = User.serializer;
// Serialize 'john' to dense JSON.
const johnDenseJson = serializer.toJson(john);
// With dense JSON, structs are encoded as JSON arrays.
assert(Array.isArray(johnDenseJson));
// toJsonCode() returns a string containing the JSON code.
// Equivalent to calling JSON.stringify() on toJson()'s result.
const johnDenseJsonCode = serializer.toJsonCode(john);
assert(johnDenseJsonCode.startsWith("["));
// Serialize 'john' to readable JSON.
console.log(serializer.toJsonCode(john, "readable"));
// {
// "user_id": 42,
// "name": "John Doe",
// "quote": "Coffee is just a socially acceptable form of rage.",
// "pets": [
// {
// "name": "Dumbo",
// "height_in_meters": 1,
// "picture": "🐘"
// }
// ],
// "subscription_status": "FREE"
// }
// The dense JSON flavor is the flavor you should pick if you intend to
// deserialize the value in the future. Skir allows fields to be renamed, and
// because fields names are not part of the dense JSON, renaming a field does
// not prevent you from deserializing the value.
// You should pick the readable flavor mostly for debugging purposes.
// Serialize 'john' to binary format.
const johnBytes = serializer.toBytes(john);
// The binary format is not human readable, but it is slightly more compact than
// JSON, and serialization/deserialization can be a bit faster in languages like
// C++. Only use it when this small performance gain is likely to matter, which
// should be rare.Deserialization
// Use fromJson(), fromJsonCode() and fromBytes() to deserialize.
const reserializedJohn = serializer.fromJsonCode(johnDenseJsonCode);
assert(reserializedJohn.name === "John Doe");
const reserializedJane = serializer.fromJsonCode(
serializer.toJsonCode(jane, "readable"),
);
assert(reserializedJane.name === "Jane Doe");
assert(serializer.fromJson(johnDenseJson).name === "John Doe");
assert(serializer.fromBytes(johnBytes.toBuffer()).name === "John Doe");Frozen arrays and copies
const pets = [
User.Pet.create<"partial">({ name: "Fluffy" }),
User.Pet.create<"partial">({ name: "Fido" }),
];
const jade = User.create<"partial">({
pets: pets,
// ^ makes a copy of 'pets' because 'pets' is mutable
});
// jade.pets.push(...)
// ^ Compile-time error: pets is readonly
assert(jade.pets !== pets);
const jack = User.create<"partial">({
pets: jade.pets,
// ^ doesn't make a copy because 'jade.pets' is frozen
});
assert(jack.pets === jade.pets);Keyed arrays
const userRegistry = UserRegistry.create({
users: [john, jane, lylaMut, evilJane],
});
// searchUsers() returns the user with the given key (specified in the .skir
// file). In this example, the key is the user id.
// The first lookup runs in O(N) time, and the following lookups run in O(1)
// time.
assert(userRegistry.searchUsers(42) === john);
assert(userRegistry.searchUsers(100) === undefined);
// If multiple elements have the same key, the search method returns the last
// one. Duplicates are allowed but generally discouraged.
assert(userRegistry.searchUsers(43) === evilJane);Constants
console.log(TARZAN);
// User {
// userId: 123,
// name: 'Tarzan',
// quote: 'AAAAaAaAaAyAAAAaAaAaAyAAAAaAaAaA',
// pets: [ User_Pet { name: 'Cheeta', heightInMeters: 1.67, picture: '🐒' } ],
// subscriptionStatus: SubscriptionStatus {
// kind: 'trial',
// value: SubscriptionStatus_Trial { startTime: [Timestamp] }
// }
// }Skir services
Starting a skir service on an HTTP server - full example here.
Sending RPCs to a skir service - full example here.
Reflection
Reflection allows you to inspect a skir type at runtime.
const fieldNames: string[] = [];
for (const field of User.serializer.typeDescriptor.fields) {
const { name, number, property, type } = field;
fieldNames.push(name);
}
console.log(fieldNames);
// [ 'user_id', 'name', 'quote', 'pets', 'subscription_status' ]
// A type descriptor can be serialized to JSON and deserialized later.
const typeDescriptor = parseTypeDescriptorFromJson(
User.serializer.typeDescriptor.asJson(),
);Writing unit tests
With mocha and buckwheat.
expect(tarzan).toMatch({
name: "Tarzan",
quote: /^A/, // must start with the letter A
pets: [
{
name: "Cheeta",
heightInMeters: near(1.6, 0.1),
},
],
subscriptionStatus: {
union: {
kind: "trial",
value: {
startTime: Timestamp.fromUnixMillis(1234),
},
},
},
// `userId` is not specified so it can be anything
});