Ryan Scott Brown

I build cloud-based systems for startups and enterprises. My background in operations gives me a unique focus on writing observable, reliable software and automating maintenance work.

I love learning and teaching about Amazon Web Services, automation tools such as Ansible, and the serverless ecosystem. I most often write code in Python, TypeScript, and Rust.

B.S. Applied Networking and Systems Administration, minor in Software Engineering from Rochester Institute of Technology.

    Trying Out Typespec

    My favorite part of working in GraphQL isn’t any of the runtime stuff. Applications I’ve worked on haven’t suffered from the over-fetching so commonly pointed to by GraphQL advocates as an inefficiency. Having a schema definition that is easily maintained and written without much of a learning curve is what makes GraphQL great. Recently I discovered Typespec, which has a familiar struct-like Schema Definition Language (SDL), decorators, and custom transformers. It also doesn’t require runtime application changes – you can use it for defining schemas without making any application changes.

    There are already schema definition languages galore either standalone or as part of larger tools. XML schema definitions, JSONSchema, OpenAPI/Swagger, Smithy, Avro, Coral, Protobuf, and Thrift are just some that come to mind. Each comes with its own tooling and (sometimes) wire format. A good SDL must have a strict schema, cross-language support, and incremental adoptability.

    To have something concrete to look at, let’s specialize a string into UUIDv4 format. Trust me on the regex, but this will match 5f881007-5450-4950-ac19-77ae8cb93be4. With this, we’ll be able to specify a UUID type and it will bring along the length and regex rules. The regex having character counts technically makes the length specification redundant, but let’s be extra specific.

    @minLength(32)
    @maxLength(32)
    @pattern(
      "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
      "Must be a UUID string."
    )
    scalar UUID extends string;
    

    Building on that, here’s the Typespec schema for an error type that would be at home in almost any application.

    @error
    model ErrorDetail {
      @minLength(4)
      @maxLength(32)
      @pattern(
        "^[A-Z][A-Z0-9_]+[A-Z0-9]$",
        "Must be 4-32 characters long in SCREAMING_SNAKE_CASE."
      )
      code: string;
    
      message?: string;
    }
    
    @error
    model NotFoundDetail extends ErrorDetail {
      code: "NOT_FOUND";
    
      request_id: UUID
    }
    

    An error might be as sparse as {"code": "OH_NO"} or have an optional explanatory message. With extends, ErrorDetail can have request ID (using our UUID type from earlier) in addition to code and message to simplify troubleshooting.

    So far we’ve only used Typespec’s basic struct-style definitions. What makes Typespec great is that its core is protocol-agnostic: we could use it for a REST API, an event, or a log message format. To use Typespec you’ll need to export to a format that can be enforced by your application and used by frameworks as you build. I use the OpenAPI 3/Swagger export feature with the REST module to add paths, HTTP verbs, and status codes. We can wrap NotFoundDetail with a type that includes an HTTP status code.

    @error
    model NotFound {
      @statusCode _: 404;
      body: NotFoundDetail;
    }
    

    Typespec can then render this to OpenAPI 3. Then we can use any tool that accepts OpenAPI to generate docs, clients, and validators.

    A Small API

    Let’s put this together with an API to return information about all the different ISO 3166 countries and sub-national divisions (provinces, states, and so on). We can send in a 3-letter code and get back all the sub-divisions of that country, such as GET /v1/regions/USA to receive a list of states.

    import "@typespec/http";
    
    using TypeSpec.Http;
    
    @service({
      title: "Nations",
    })
    @server("http://localhost:3000", "Local dev")
    namespace Nations;
    
    @error
    model ErrorDetail {
      @minLength(4)
      @maxLength(32)
      @pattern(
        "^[A-Z][A-Z0-9_]+[A-Z0-9]$",
        "Must be 4-32 characters long in SCREAMING_SNAKE_CASE."
      )
      code: string;
    
      message?: string;
    }
    
    @error
    model NotFoundDetail extends ErrorDetail {
      code: "NOT_FOUND";
    }
    
    @error
    model NotFound {
      @statusCode _: 404;
      body: NotFoundDetail;
    }
    
    @route("/v1/regions")
    namespace Region {
      model Country {
        @minLength(3)
        @maxLength(3)
        code: string;
    
        name: string;
        divs: Subdivision;
      }
      model Subdivision {
        name: string;
        code: string;
        div_type: string;
      }
      model Countries {
        countries: Country[];
      }
    
      @get
      op get(@path code: string): {
        @statusCode _: 200;
        body: {
          country?: Country;
        };
      } | NotFound;
    }
    

    The below YAML version of the same specification has all our properties converted to OpenAPI, and the namespace Region has been applied to all the types inside the namespace. Also note that our above op get has a union operator, |, that shows up in the OpenAPI as the 200 and 404 status codes and responses.

    # compiled with `tsp compile . --option "@typespec/openapi3.file-type=yaml"`
    openapi: 3.0.0
    info:
      title: Nations
      version: 0.0.0
    servers:
      - url: http://localhost:3000
        description: Local dev
        variables: {}
    paths:
      /v1/regions/{code}:
        get:
          operationId: Region_get
          parameters:
            - name: code
              in: path
              required: true
              schema:
                type: string
          responses:
            '200':
              description: The request has succeeded.
              content:
                application/json:
                  schema:
                    type: object
                    properties:
                      body:
                        type: object
                        properties:
                          country:
                            $ref: '#/components/schemas/Region.Country'
                    required:
                      - body
            '404':
              description: The server cannot find the requested resource.
              content:
                application/json:
                  schema:
                    $ref: '#/components/schemas/NotFound'
    components:
      schemas:
        ErrorDetail:
          type: object
          required:
            - code
          properties:
            code:
              type: string
              minLength: 4
              maxLength: 32
              pattern: ^[A-Z][A-Z0-9_]+[A-Z0-9]$
            message:
              type: string
        NotFound:
          type: object
          required:
            - body
          properties:
            body:
              $ref: '#/components/schemas/NotFoundDetail'
        NotFoundDetail:
          type: object
          required:
            - code
          properties:
            code:
              type: string
              enum:
                - NOT_FOUND
          allOf:
            - $ref: '#/components/schemas/ErrorDetail'
        Region.Country:
          type: object
          required:
            - code
            - name
            - divs
          properties:
            code:
              type: string
              minLength: 3
              maxLength: 3
            name:
              type: string
            divs:
              $ref: '#/components/schemas/Region.Subdivision'
        Region.Subdivision:
          type: object
          required:
            - name
            - code
            - div_type
          properties:
            name:
              type: string
            code:
              type: string
            div_type:
              type: string
    

    Conclusions

    Like a bad ORM, a bad SDL can overwhelm its own usefulness with impedance mismatch for your language or workload. JSONSchema is great at specifying payloads, but won’t help you design something like a RESTful API because it lacks knowledge of HTTP verbs and paths. gRPC’s assumption of an RPC-style system makes modeling evented systems like message buses difficult. Typespec mainly seems focused on HTTP APIs, but its modular nature and output plugins mean it’s extensible to other use cases.

    Smithy is AWS’ entry into the SDL arena and attempts to learn lessons from the older Coral SDL. It suffers from a limited tooling ecosystem because so few outside of Amazon have adopted it. In contrast, Typespec users can leverage existing OpenAPI-driven tools for client generation, docs, and more.

    Back to GraphQL, and the promise to solve over-fetching. In GraphQL-based applications I’ve seen devs copy every field in the schema to their requests. To best address over-fetching, use a query fragment assembling system like Apollo layers on top of your web/mobile application. Otherwise, each component in your web UI may develop its own slightly different set of fields and duplicate requests regardless of your SDL.

    Typespec doesn’t attempt to solve run-time problems beyond validation, but within the project’s stated goals the team has put together an elegant SDL and chosen good integrations points with the broader ecosystem.

    Design by Sam Lucidi (samlucidi.com)