scala-ts-generation
How scala-ts maps Scala types to TypeScript types and io-ts codecs — the parse/render pipeline, the full mapping table, naming, imports, and the footguns consumers hit. Use when changing generated out
SKILL.md.claude/skills/scala-ts-generation/SKILL.md
GitHubScala → TypeScript generation
Why it exists
One owner per fact: a shape is written once, in Scala, and generated into TypeScript, so server and client cannot disagree without one side failing to compile. Every Scala sum encodes (with circe's _tag discriminator) as a tagged union that TypeScript narrows and folds on directly. That only holds if the generated types are at least as precise as the Scala ones.
Pipeline
parse[A] (macro, compile time, TsParser) → TsModel (data) → generateAll (TsGenerator.generateTopLevel per model) → writeAll (resolve imports per output file, TypeSorter.sort, write). parseReference[A] emits a reference only — the referenced type must be generated in some file passed to the same writeAll, or resolution throws UnresolvedTsImportException at run time.
Mapping
| Scala | TsModel |
Codec | Value type |
|---|---|---|---|
Byte/Short/Int/Long/Double/Float |
Number |
t.number |
number |
BigDecimal/BigInt |
BigNumber |
configured iotsBigNumber (no default) |
configured |
Boolean |
Boolean |
t.boolean |
boolean |
String |
String |
t.string |
string |
java.time.LocalDate, joda LocalDate |
LocalDate |
configured iotsLocalDate (no default) |
configured |
LocalDateTime/ZonedDateTime/Instant, joda DateTime |
DateTime |
DateFromISOString (default) |
Date |
java.util.UUID |
UUID |
io-ts-types UUID |
UUID |
io.circe.Json |
Json |
t.unknown |
unknown |
cats.Eval[A] |
Eval |
as A |
as A |
List/Vector/Seq/Chain |
Array |
t.readonlyArray |
ReadonlyArray<A> |
Set/SortedSet |
Set |
readonlySetFromArray(c, ord) |
ReadonlySet<A> |
NonEmptyList/NonEmptyVector/NonEmptyChain, scalaz NonEmptyList |
NonEmptyArray |
readonlyNonEmptyArray |
RNEA.ReadonlyNonEmptyArray<A> |
Option[A] |
Option |
optionFromNullable |
O.Option<A> |
Either, scalaz \/ |
Either |
io-ts-types either |
E.Either<L, R> |
cats Ior, scalaz \&/ |
Ior |
configured iotsThese (no default) |
Th.These<L, R> |
Map[String, V] |
Map |
t.record(t.string, v) |
Record<string, V> |
Map[K, V] (other K) |
Map |
readonlyMapFromEntries(k, ord, v) |
ReadonlyMap<K, V> |
| tuple | Tuple |
t.tuple([...]) |
[A, B] |
Scala 3 union A | B (reference only) |
UnionTypeRef |
t.union([...]) |
A | B |
case class |
Interface |
t.type({...}) |
{ ... } |
field-less, non-generic case class outside a union |
Interface |
t.UnknownRecord |
Record<string, unknown> |
object |
Object |
t.type of literal fields, plus a const of values |
literal fields |
sealed trait / enum |
Union |
t.union([...]) |
A | B |
| type alias (non-generic) | TypeAlias |
as the dealiased type, under the alias's name | |
| anything else | Unknown |
an import of <name>C the consumer must provide |
Set and non-String-keyed Map need an Ord for the element/key: built in for string/number/boolean, Option/array of those, and all-object unions (xOrd); otherwise supply one through TsCustomOrd, or generation fails with "Ord instance requested for … but not found".
Naming
- Codec const:
decap(base) + "C"(Foo→fooC; all-caps names keep their case). Codec type:capof that (FooC). Value type:Foo. - Unions: codec and codec type
FooCU, value typeFooU; plusallFooC,allFooNames,FooName,FooMap<A>(non-generic unions),allFoo(all-object unions) andfooOrd(all-object, non-generic unions). - Cross-file imports are aliased
importedN_Name; same-file references are rewritten back to the plain name duringresolve.
Consumer customization
TsImports.Config— where each fp-ts/io-ts/io-ts-types reference comes from;iotsBigNumber,iotsLocalDateandiotsThesehave no default and fail generation with "… import config value missing" if a type needs them.TsCustomType— replace generation for a type, matched on its fully-qualified name string. Renaming or moving the Scala type silently falls back to structural generation.TsCustomOrd— supplyOrdinstances byTypeName.
Footguns
- Run-time, not compile-time, failures. Unregistered references (
UnresolvedTsImportException), missing config (sys.error), missingOrd, and empty unions all fail when the consumer runs generation, not when it compiles. - Union types are references only.
parse[A | B]at the top level aborts compilation ("Union types are only supported as references"). - Same type in two files.
generateAllde-duplicates per file by full type name, andmakeTypeToFilekeeps the last file seen; register each type in one file. - Output changes are breaking for every consumer. Their generated files change on upgrade; describe the before/after TypeScript in the PR and bump
versioninbuild.sbtfor a release.