A Scala 3 macro library that generates TypeScript types and io-ts codecs from Scala types. The Scala types are the source of truth for an API's I/O types; the generated TypeScript hardens the contract between the frontend and the backend.
- Installation
- Quickstart
- Output
- Configuration
- Optional dependencies
- Codec target
- Public API
- Code structure
- License
// build.sbt
resolvers += "bondlink-maven-repo" at "https://maven.bondlink-cdn.com"
libraryDependencies += "bondlink" %% "scala-ts" % "0.21.0"Requires Scala 3 (currently built against 3.3.7).
The recommended setup is to define your types in one sbt subproject and your generator in another. The generator project depends on the types project and runs as a normal main method, regenerating TypeScript files on demand.
-
Define your types:
// build.sbt lazy val types = project.in(file("types")) // types/src/main/scala/com/example/Role.scala package com.example enum Role { case User, Admin, SuperAdmin } // types/src/main/scala/com/example/User.scala package com.example case class User(name: String, email: String, role: Role)
-
Define a generator project that depends on
typesand onscala-ts:// build.sbt lazy val generate = project.in(file("generate")) .dependsOn(types) .settings( resolvers += "bondlink-maven-repo" at "https://maven.bondlink-cdn.com", libraryDependencies += "bondlink" %% "scala-ts" % "0.21.0", ) // generate/src/main/scala/com/example/Generate.scala package com.example import java.io.File import scalats.{parse, TsCustomOrd, TsCustomType, TsImports, writeAll} object Generate { given customOrd: TsCustomOrd = TsCustomOrd.none given customType: TsCustomType = TsCustomType.none given imports: TsImports.Available = TsImports.Available(TsImports.Config()) def main(args: Array[String]): Unit = writeAll(Map( new File("/path/to/your/repo/generated/role.ts") -> List(parse[Role]), new File("/path/to/your/repo/generated/user.ts") -> List(parse[User]), )) }
-
Regenerate TypeScript whenever the Scala types change:
sbt generate/run
The output generated from the quickstart example looks like this:
expand generated/role.ts
import * as t from "io-ts";
import { Ord as stringOrd } from "fp-ts/lib/string";
import * as E from "fp-ts/lib/Either";
import { pipe } from "fp-ts/lib/function";
import * as Ord from "fp-ts/lib/Ord";
export const user = {
_tag: `User`
} as const;
export type UserTaggedC = t.TypeC<{
_tag: t.LiteralC<`User`>
}>;
export const userTaggedC: UserTaggedC = t.type({
_tag: t.literal(`User`)
});
export type UserTagged = t.TypeOf<UserTaggedC>;
export type User = UserTagged & typeof user;
export type UserC = t.Type<User, UserTagged>;
export const userC: UserC = pipe(userTaggedC, c => new t.Type<User, UserTagged>(
`User`,
(u: unknown): u is User => E.isRight(c.decode(u)),
(u: unknown): E.Either<t.Errors, User> => pipe(c.decode(u), E.map(x => ({ ...x, ...user }))),
(x: User): UserTagged => ({ ...x, _tag: `User`}),
)) satisfies t.Type<User, unknown>;
export const admin = {
_tag: `Admin`
} as const;
export type AdminTaggedC = t.TypeC<{
_tag: t.LiteralC<`Admin`>
}>;
export const adminTaggedC: AdminTaggedC = t.type({
_tag: t.literal(`Admin`)
});
export type AdminTagged = t.TypeOf<AdminTaggedC>;
export type Admin = AdminTagged & typeof admin;
export type AdminC = t.Type<Admin, AdminTagged>;
export const adminC: AdminC = pipe(adminTaggedC, c => new t.Type<Admin, AdminTagged>(
`Admin`,
(u: unknown): u is Admin => E.isRight(c.decode(u)),
(u: unknown): E.Either<t.Errors, Admin> => pipe(c.decode(u), E.map(x => ({ ...x, ...admin }))),
(x: Admin): AdminTagged => ({ ...x, _tag: `Admin`}),
)) satisfies t.Type<Admin, unknown>;
export const superAdmin = {
_tag: `SuperAdmin`
} as const;
export type SuperAdminTaggedC = t.TypeC<{
_tag: t.LiteralC<`SuperAdmin`>
}>;
export const superAdminTaggedC: SuperAdminTaggedC = t.type({
_tag: t.literal(`SuperAdmin`)
});
export type SuperAdminTagged = t.TypeOf<SuperAdminTaggedC>;
export type SuperAdmin = SuperAdminTagged & typeof superAdmin;
export type SuperAdminC = t.Type<SuperAdmin, SuperAdminTagged>;
export const superAdminC: SuperAdminC = pipe(superAdminTaggedC, c => new t.Type<SuperAdmin, SuperAdminTagged>(
`SuperAdmin`,
(u: unknown): u is SuperAdmin => E.isRight(c.decode(u)),
(u: unknown): E.Either<t.Errors, SuperAdmin> => pipe(c.decode(u), E.map(x => ({ ...x, ...superAdmin }))),
(x: SuperAdmin): SuperAdminTagged => ({ ...x, _tag: `SuperAdmin`}),
)) satisfies t.Type<SuperAdmin, unknown>;
export const allRoleC = [userC, adminC, superAdminC] as const;
export const allRoleNames = [`User`, `Admin`, `SuperAdmin`] as const;
export type RoleName = (typeof allRoleNames)[number];
export type RoleCU = t.UnionC<[UserC, AdminC, SuperAdminC]>;
export type RoleU = User | Admin | SuperAdmin;
export const RoleCU: RoleCU = t.union([userC, adminC, superAdminC]) satisfies t.Type<RoleU, unknown>;
export const roleOrd: Ord.Ord<RoleU> = pipe(stringOrd, Ord.contramap(x => x._tag));
export const allRole = [user, admin, superAdmin] as const;
export type RoleMap<A> = { [K in RoleName]: A };expand generated/user.ts
import * as t from "io-ts";
import { RoleCU as imported1_RoleCU, RoleU as imported0_RoleU, RoleCU as imported0_RoleCU } from "./role";
export type UserC = t.TypeC<{
name: t.StringC,
email: t.StringC,
role: imported1_RoleCU
}>;
export type User = {
name: string,
email: string,
role: imported0_RoleU
};
export const userC: UserC = t.type({
name: t.string,
email: t.string,
role: imported0_RoleCU
}) satisfies t.Type<User, unknown>;generateAll, writeAll, and referenceCode all require three given values. Default instances are provided but must be opted into explicitly:
| Given | Default | Purpose |
|---|---|---|
TsCustomType |
TsCustomType.none |
Override the generated TS for specific Scala types. |
TsCustomOrd |
TsCustomOrd.none |
Provide fp-ts Ord instances for types used in Sets. |
TsImports.Available |
TsImports.Available(TsImports.Config()) |
Controls where fp-ts / io-ts values are imported from. |
TsImports.Config exposes the import path used for every fp-ts and io-ts value the generator emits. Every field has a sane default; override individual fields to point at hand-written codecs or alternate libraries. A few fields are Option and have no default; they only matter if the corresponding Scala types appear in your model:
iotsLocalDate— required to supportjava.time.LocalDateandorg.joda.time.LocalDateiotsBigNumber— required to support arbitrary-precision number typesiotsThese— required to supportcats.data.Iorandscalaz.\&/
TsCustomType lets you map a Scala type name to hand-written TypeScript. Use it when you have a Scala type whose default representation is not what you want, or whose representation is not built in. This is the intended extension point for adding support for additional types.
For example, you might have a Money type in Scala for which you'd like to handwrite an io-ts codec. To handle it, you would define your TsCustomType instance to point to the TypeScript codec and type:
val moneyPath = "/path/to/custom/codecs/money.ts"
given customType: TsCustomType = new TsCustomType {
def apply(name: String): Option[ReferenceCode[Option]] = name match {
case "com.example.Money" =>
Some(ReferenceCode(
// Assumes `given imports: TsImports.Available` is in scope
codecType = imports.namedImport(moneyPath, "MoneyC"),
codecInstance = imports.namedImport(moneyPath, "moneyC"),
valueType = Some(imports.namedImport(moneyPath, "Money")),
valueInstance = None,
))
case _ => None
}
}A Scala Set[A] is generated as ReadonlySet<A>, which from fp-ts requires an Ord<A>. Built-in Ord instances cover the primitive types; for any other element type that appears in a Set, supply an Ord via TsCustomOrd:
given customOrd: TsCustomOrd = new TsCustomOrd {
def apply(typeName: TypeName): Option[Generated] = typeName.full match {
case "java.time.LocalDate" =>
// Assumes `given imports: TsImports.Available` is in scope
Some(imports.namedImport("/path/to/localDateOrd.ts", "localDateOrd"))
case _ => None
}
}Ord instances are only required for types that are used within Set elements.
scala-ts declares circe, joda-time, and scalaz as Optional dependencies. None of them are required, but if any are on the user's classpath the generator picks up additional type support automatically.
io.circe.Json— represented in TypeScript asunknown
org.joda.time.LocalDate— requiresiotsLocalDateto be set onTsImports.Config(for example, pointing atjs-joda'sLocalDate)org.joda.time.DateTime— represented by default asDate, decoded withio-ts-types/lib/DateFromISOString. OverrideiotsDateTimeonTsImports.Configto substitute another type, e.g.js-joda'sZonedDateTime
scalaz.NonEmptyList—ReadonlyNonEmptyArrayfromfp-tsscalaz.\&/—Thesefromfp-ts(requiresiotsTheseto be configured)scalaz.\/—Eitherfromfp-ts
The generated codecs are hardcoded to io-ts. The codec layer could in principle be made pluggable, but that is not currently planned.
All public entry points live in the scalats package.
parse[A]— parse a top-level Scala typeAinto aTsModelfor definition. Use this for types you want to generate in TypeScript.parseReference[A]— parseAas a reference only. Use this when you want to refer toAfrom generated code without redefining it.
generateAll(all, debug, debugFilter)— turn aMap[File, List[TsModel]]into an intermediate IR that can be fed towriteAllorresolvewriteAll(all)— write the generated IR to diskwriteAll(all, debug, debugFilter)— convenience overload that runsgenerateAlland writes the result in one call. This is what the quickstart usesresolve(currFile, generated, allGenerated)— resolve the imports of a single piece of generated code in the context of a larger generation pass. Useful when assembling output yourselfreferenceCode(model)— produce the snippets needed to refer to a parsed type from hand-written generator code (codec type, codec instance, value type, value instance)
The library is small enough that a one-paragraph map is enough to find your way around src/main/scala/scalats:
package.scala— public API:parse,parseReference,generateAll,writeAll,resolve,referenceCodeTsParser.scala— the macro that walks a Scala type at compile time and produces aTsModelTsModel.scala— the intermediate representation: every shape the parser can recognize (primitives, collections, interfaces, objects, unions, opaque types, type aliases, etc.). Interfaces, objects, and unions have both a definition form and a reference form so referenced types are not re-parsedTsGenerator.scala— turns aTsModelinto TypeScript types andio-tscodecs. The bulk of the rendering logic lives hereTsImports.scala— the import bookkeeping system, includingTsImports.Config(where everyfp-ts/io-tsvalue is imported from) andTsImports.Available(the live set of imports during generation). Imports can be unresolved at write time and are resolved against the full set of generated filesTsCustomType.scala/TsCustomOrd.scala— the user-facing extension pointsTypeSorter.scala— topologically sorts generated types so that dependencies appear before dependents within a fileTypeName.scala,TypeParam.scala,ReferenceCode.scala,Generated.scala— small supporting typesReflectionUtils.scala— macro helpers forTsParser
MIT. See LICENCE.txt.