Skip to content

Add support for custom types with typescript_type_name in NewType - #99

Open
noelgbl wants to merge 4 commits into
ash-project:mainfrom
noelgbl:feat/newtype-map-typescript-type-name
Open

noelgbl wants to merge 4 commits into
ash-project:mainfrom
noelgbl:feat/newtype-map-typescript-type-name

Conversation

@noelgbl

@noelgbl noelgbl commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

A NewType of :map without fields constraints ignored typescript_type_name/0 and was generated as the untyped map type (Record<string, any>). Scalar NewTypes already use the callback (see Percentage), so map NewTypes were the only ones where it had no effect.

defmodule MyApp.Layout do
  use Ash.Type.NewType, subtype_of: :map
  def typescript_type_name, do: "CustomTypes.Layout"
end
layout: CustomTypes.Layout | null; // was: Record<string, any> | null

Change

  • In TypeMapper.map_typed_container/2, a container without fields now goes through unknown_module_mapping/1, the same lookup scalar NewTypes use, before falling back to untyped_map_type.
  • Maps with fields still generate their typed map, which field selection depends on.

Tests

  • New Todo.Layout fixture (a map NewType), added as the layout attribute on Todo.
  • custom_types_test.exs: the type on its own, inside an array, and in the full generated output.
  • test/ts/shouldPass/customTypes.ts: layout is typed as CustomTypes.Layout. This check fails on the old codegen.
  • Docs: new section in documentation/advanced/custom-types.md.

Contributor checklist

  • I accept the AI Policy, or AI was not used in the creation of this PR.
  • Bug fixes include regression tests
  • Chores
  • Documentation changes
  • Features include unit/acceptance tests
  • Refactoring
  • Update dependencies

@Torkan

Torkan commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator

Ok, we can add this if we also ensure that codegen fails if typescript_type_name is defined when constraints are also set, asking the user to remove one or the other 😄

Can you add that as well @noelgbl?

@noelgbl

noelgbl commented Oct 3, 2026

Copy link
Copy Markdown
Contributor Author

Good catch, Done!

@Torkan Torkan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding that so quickly @noelgbl! 🙏

While testing it I realised my earlier suggestion was too broad, sorry about that. "Constraints" covers far more than I meant. The only real conflict is a fields constraint: that's the one case where map_typed_container/2 builds the typed map and quietly ignores typescript_type_name/0. Other constraints (min/max, max_length, defaults declared in a custom type's constraints/0) don't change the TS shape. Rejecting them breaks setups that work on main today.

Inline I've put the cases I hit, a suggestion for narrowing the check, and a small thing about the error message.

end

@doc false
def custom_type_with_constraints?(%Type{module: module, constraints: constraints}) do

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does what I asked for, but in practice it catches too much. On this branch both of these now fail codegen, and main accepts them:

# Constrained scalar NewType. On main it maps to "CustomTypes.Percentage".
use Ash.Type.NewType, subtype_of: :float, constraints: [min: 0.0, max: 100.0]
def typescript_type_name, do: "CustomTypes.Percentage"
# `use Ash.Type` with a defaulted constraint. Flagged even when the attribute
# passes no constraints, because Ash fills in the default.
def constraints, do: [precision: [type: :integer, default: 2]]
def typescript_type_name, do: "string"

The result also depends on where the constraints are set. For NewTypes the usage-site constraints are dropped, so attribute :x, PlainPct, constraints: [min: 0.0] passes, while the same constraints inside the NewType's use fail.

Could we only flag when the effective constraints contain fields? That's exactly when the name gets dropped. Roughly:

def custom_type_with_constraints?(%Type{module: module} = type) do
  is_atom(module) and Introspection.is_custom_type?(module) and Type.get_fields(type) != []
end

(Or a Keyword.has_key?(..., :fields) check on the folded constraints, whichever fits better.) A name like custom_type_with_fields?/1 would then describe it better.

@@ -133,16 +146,20 @@ defmodule AshTypescript.Manifest.Verifiers.VerifyMappableTypes do
defp build_error(offenders, module) do

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small one: the per-type conflict line is great, but the surrounding header ("Unsupported types found…") and footer ("implement the typescript_type_name/0 callback…") are always added. So when the only offender is a conflict, the footer tells the user to add the callback that's causing it 😅

Rather than splitting by reason, maybe a generic header/footer that covers both cases would work, e.g.:

Some types cannot be mapped to TypeScript:

...

Each custom type needs exactly one way to map to TypeScript. Either:
  - implement the `typescript_type_name/0` callback on the type module, or
  - add an entry to `config :ash_typescript, type_mapping_overrides: [{TheModule, "<ts type>"}]`, or
  - (map types) declare `fields` constraints so AshTypescript generates a typed map

These are mutually exclusive: a type with `fields` constraints cannot also use
`typescript_type_name/0`. Remove one or the other.

The per-type line could then say "defines both typescript_type_name/0 and fields constraints", so it's clear which entries are conflicts and which are missing mappings. (This assumes the check is narrowed to fields as in the other comment.)

end
end

describe "custom type conflicts" do

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add a few cases that pin down the boundary? None of the current fixtures has constraints, which is why the suite stayed green:

  • scalar NewType with constraints: [min:, max:] + typescript_type_name/0 → accepted
  • use Ash.Type with a defaulted entry in constraints/0 + typescript_type_name/0 → accepted
  • map NewType with fields + typescript_type_name/0 → rejected (your existing test), plus an assertion that the message uses the new mutually-exclusive wording rather than only "implement typescript_type_name/0"

@noelgbl

noelgbl commented Oct 9, 2026

Copy link
Copy Markdown
Contributor Author

@Torkan thanks for review. Hope I got everything covered.

@noelgbl
noelgbl requested a review from Torkan October 19, 2026 08:21

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants