Working with Structured Data in Deno and TypeScript

One of the features in Go that I miss in TypeScript is Go’s DSL for expressing data representations. Adding JSON, YAML and XML support in Go is simple. Annotating a struct with a string expression. There is no equivalent feature in TypeScript. How do easily support multiple representations in TypeScript?

Let’s start with JSON. TypeScript has JSON.stringify() and JSON.parse(). So getting to JSON representation is trivial, just call the stringify method. Going from text to populated object is done with JSON.parse. But there is a catch.

Let’s take a simple object I’m defining called “ObjectN”. The object has a single attribute “n”. “n” holds a number. The initial values is set to zero. What happens when I instantiate my ObjectN then assign it the result from JSON.parse().

  1. class ObjectN {
  2. n: number = 0;
  3. addThree(): number {
  4. return this.n + 3;
  5. }
  6. }
  7. let src = `{"n": 1}`;
  8. let o: ObjectN = new ObjectN();
  9. o = JSON.parse(src);
  10. // NOTE: This will fail, addThree method isn't available.
  11. console.log(o.addThree());

Huston, we have a problem. No “addThree” method. That is because JSON doesn’t include method representation. What we really want to do is inspect the object returned by JSON.parse() and set the values in our ObjectN accordingly. Let’s add a method called fromObject(). (type the following into the Deno REPL).

  1. class ObjectN {
  2. n: number = 0;
  3. addThree(): number {
  4. return this.n + 3;
  5. }
  6. fromObject(o: {[key: string]: any}): boolean {
  7. if (o.n === undefined) {
  8. return false;
  9. }
  10. // Validate that o.n is a number before assigning it.
  11. const n = (new Number(o.n)).valueOf();
  12. if (isNaN(n)) {
  13. return false;
  14. }
  15. this.n = n;
  16. return true;
  17. }
  18. }
  19. let src = `{"n": 1}`;
  20. let o: ObjectN = new ObjectN();
  21. console.log(o.addThree());
  22. o.fromObject(JSON.parse(src));
  23. console.log(o.addThree());

Now when we run this code we should see a “3” and then a “4” output. Wait, o.fromObject(JSON.parse(src)); looks weird. Why not put JSON.parse() inside “fromObject”? Why not renamed it “parse”?

I want to support many types of data conversion like YAML or XML. I can use my “fromObject” method with the result of produced from JSON.parse(), yaml.parse() and xml.parse(). One function works with the result of all three. Try adding this.

  1. import * as yaml from 'jsr:@std/yaml';
  2. import * as xml from "jsr:@libs/xml";
  3. src = `n: 2`;
  4. o.fromObject(yaml.parse(src));
  5. console.log(o.addThree());
  6. src = `<n>3</n>`;
  7. o.fromObject(xml.parse(src));
  8. console.log(o.addThree());

That works!

Still it would be nice to have a “parse” method too. How do I do that without winding up with a “parseJSON()”, “parseYAML()” and “parseXML()”? What I really want is a “parseWith” method which accepts the text and a parse function. TypeScript expects type information about the function being passed. I solve that problem by including a “ObjectParseType” definition that works across the three parsing objects – JSON, yaml and xml.

  1. import * as yaml from 'jsr:@std/yaml';
  2. import * as xml from "jsr:@libs/xml";
  3. // This defines my expectations of the parse function provide by JSON, yaml and xml.
  4. type ObjectParseType = (arg1: string, arg2?: any) => {[key: string]: any} | unknown;
  5. class ObjectN {
  6. n: number = 0;
  7. addThree(): number {
  8. return this.n + 3;
  9. }
  10. fromObject(o: {[key: string]: any}) : boolean {
  11. if (o.n === undefined) {
  12. return false;
  13. }
  14. // Validate that o.n is a number before assigning it.
  15. const n = (new Number(o.n)).valueOf();
  16. if (isNaN(n)) {
  17. return false;
  18. }
  19. this.n = n;
  20. return true;
  21. }
  22. parseWith(s: string, fn: ObjectParseType): boolean {
  23. return this.fromObject(fn(s) as unknown as {[key: string]: any});
  24. }
  25. }
  26. let o: ObjectN = new ObjectN();
  27. console.log(`Initial o.addThree() -> ${o.addThree()}`);
  28. console.log(`o.toString() -> ${o.toString()}`);
  29. let src = `{"n": 1}`;
  30. o.parseWith(src, JSON.parse);
  31. console.log(`parse with JSON, o.addThree() -> ${o.addThree()}`);
  32. console.log(`JSON.stringify(o) -> ${JSON.stringify(o)}`);
  33. src = `n: 2`;
  34. o.parseWith(src, yaml.parse);
  35. console.log(`parse with yaml, o.addThree() -> ${o.addThree()}`);
  36. console.log(`yaml.stringify(o) -> ${yaml.stringify(o)}`);
  37. src = `<?xml version="1.0"?>
  38. <n>3</n>`;
  39. o.parseWith(src, xml.parse);
  40. console.log(`parse with xml, o.addThree() -> ${o.addThree()}`);
  41. console.log(`xml.stringify(o) -> ${xml.stringify(o)}`);

As long as the parse method returns an object I can now update my ObjectN instance from the attributes of the object expressed as JSON, YAML, or XML strings. I like this approach because I can add validation and normalization in my “fromObject” method and use for any parse method that confirms to how JSON, YAML or XML parse works. The coding cost is the “ObjectParseType” type definition and the “parseWith” method boiler plate and defining a class specific “fromObject”. Supporting new representations does require changes to my class definition at all.