AshTypescript supports custom Ash types with TypeScript integration. This guide covers how to create custom types and map dependency types to TypeScript.

Creating Custom Ash Types

Create custom Ash types that map to TypeScript types:

Basic Custom Type

# 1. Create custom type in Elixir
defmodule MyApp.PriorityScore do
  use Ash.Type

  def storage_type(_), do: :integer
  def cast_input(value, _) when is_integer(value) and value >= 1 and value <= 100, do: {:ok, value}
  def cast_input(_, _), do: {:error, "must be integer 1-100"}
  def cast_stored(value, _), do: {:ok, value}
  def dump_to_native(value, _), do: {:ok, value}
  def apply_constraints(value, _), do: {:ok, value}

  # AshTypescript integration - specify the TypeScript type
  def typescript_type_name, do: "CustomTypes.PriorityScore"
end
// 2. Create TypeScript type definitions in customTypes.ts
export type PriorityScore = number;

export type ColorPalette = {
  primary: string;
  secondary: string;
  accent: string;
};
# 3. Configure custom type imports
# config/config.exs
config :ash_typescript,
  import_into_generated: [
    %{
      import_name: "CustomTypes",
      file: "./customTypes"
    }
  ]
# 4. Use in your resources
defmodule MyApp.Todo do
  use Ash.Resource, domain: MyApp.Domain

  attributes do
    uuid_primary_key :id
    attribute :title, :string, public?: true
    attribute :priority_score, MyApp.PriorityScore, public?: true
  end
end

The generated TypeScript will automatically include your custom types:

// Generated TypeScript includes imports
import * as CustomTypes from "./customTypes";

// Your resource types use the custom types
interface TodoFieldsSchema {
  id: string;
  title: string;
  priorityScore?: CustomTypes.PriorityScore | null;
}

Built-In Third-Party Type Support

Some common third-party Ash types are mapped out of the box — no callback or override needed:

Type moduleGenerated TypeScript
AshMoney.Types.MoneyMoney alias ({ amount, currency }, with matching Zod/Valibot object schemas)
AshPostgres.LtreeAshPostgresLtreeArray / AshPostgresLtreeFlexible alias (depending on the escape? constraint)
AshDoubleEntry.ULIDULID (string) alias

Any other type module without a TypeScript mapping fails compilation with "Unsupported types found — AshTypescript cannot map them to TypeScript", listing each offender and its location. Fix it with one of the approaches below.

Type Mapping Overrides

When using custom Ash types from dependencies (where you can't add the typescript_type_name/0 callback), use the type_mapping_overrides configuration to map them to TypeScript types.

Configuration

# config/config.exs
config :ash_typescript,
  type_mapping_overrides: [
    {AshUUID.UUID, "string"},
    {SomeComplex.Custom.Type, "CustomTypes.MyCustomType"}
  ]

Example: Mapping Dependency Types

# Suppose you're using a third-party library with a custom type
defmodule MyApp.Product do
  use Ash.Resource,
    domain: MyApp.Domain,
    extensions: [AshTypescript.Resource]

  typescript do
    type_name "Product"
  end

  attributes do
    uuid_primary_key :id
    attribute :name, :string, public?: true

    # Type from a dependency - can't modify it to add typescript_type_name
    attribute :uuid, AshUUID.UUID, public?: true
    attribute :some_value, SomeComplex.Custom.Type, public?: true
  end
end
# Configure the type mappings
config :ash_typescript,
  type_mapping_overrides: [
    # Map to built-in TypeScript type
    {AshUUID.UUID, "string"},

    # Map to custom type (requires defining the type in customTypes.ts)
    {SomeComplex.Custom.Type, "CustomTypes.MyCustomType"}
  ],

  # Import your custom types
  import_into_generated: [
    %{
      import_name: "CustomTypes",
      file: "./customTypes"
    }
  ]
// customTypes.ts - Define the MyCustomType type
export type MyCustomType = {
  someField: string;
  anotherField: number;
};

Generated TypeScript:

import * as CustomTypes from "./customTypes";

interface ProductResourceSchema {
  id: string;
  name: string;
  uuid: string;                        // Mapped to built-in string type
  someValue: CustomTypes.MyCustomType; // Mapped to custom type
}

When to Use Each Approach

ApproachUse When
typescript_type_name/0 callbackYou control the Ash type definition
type_mapping_overridesThe type is from a dependency you can't modify

Validation Schemas for Custom Types

type_mapping_overrides and typescript_type_name/0 control the generated TypeScript type. They do not affect the generated Zod/Valibot schema — that is resolved separately, and has its own set of options.

Prefer Ash.Type.NewType

A type built with Ash.Type.NewType needs nothing at all. Its declared constraints flow into both the TypeScript type and the validation schema, for scalars, maps, arrays, and unions alike:

defmodule MyApp.Score do
  use Ash.Type.NewType, subtype_of: :integer, constraints: [min: 1, max: 100]
end
score: z.number().int().min(1).max(100).nullable().optional(),

This is the recommended approach whenever you can express your type this way.

Hand-Rolled Types Derive From Storage

A type built with use Ash.Type casts through arbitrary code, so its shape can't be inspected. AshTypescript derives a schema from storage_type/1:

storage_type/1Generated Zod
:integerz.number().int()
:floatz.number()
:booleanz.boolean()
:string, :ci_string, :atomz.string()
:uuid, :binary_idz.uuid()
:datez.iso.date()
:time, :time_usecz.string().time()
:naive_datetime, :utc_datetime, :utc_datetime_usecz.iso.datetime()
:decimal, :binaryz.string()
:map, :jsonbz.record(z.string(), z.any())
{:array, inner}z.array(<inner>)
anything elsez.any()

This is permissive but never wrong: a :map-storage type validates as a record rather than as its precise object shape. Use an override below if you need precision.

Schema Mapping Overrides

For hand-rolled types, or types from a dependency, set the schema directly:

config :ash_typescript,
  zod_mapping_overrides: [
    {SomeLib.ObjectId, ~s{z.string().brand<"ObjectId">()}}
  ],
  valibot_mapping_overrides: [
    {SomeLib.ObjectId, "v.string()"}
  ]

The two lists are independent — a type with a Zod override but no Valibot override keeps its storage-derived Valibot schema. Overrides also take precedence over the built-in third-party mappings.

Referencing a Schema You Wrote

An override can name a schema authored in TypeScript instead of an inline expression. Because the generated schema files import only their validation library, you must also declare the import, or the generated code will reference an undefined name:

config :ash_typescript,
  zod_mapping_overrides: [{SomeLib.ObjectId, "CustomZodSchemas.objectId"}],
  zod_import_into_generated: [
    %{import_name: "CustomZodSchemas", file: "assets/js/customZodSchemas.ts"}
  ]
// assets/js/customZodSchemas.ts — yours to maintain
import { z } from "zod";
export const objectId = z.string().min(3).brand<"ObjectId">();
// ash_zod.ts — generated
import { z } from "zod";
import * as CustomZodSchemas from "./customZodSchemas";

export const createTaskZodSchema = z.object({
  customId: CustomZodSchemas.objectId.nullable().optional(),
});

These keys are separate from import_into_generated, which targets the types and RPC files. Keeping them separate stops unrelated modules from being pulled into the schema files, where they could create import cycles.

No schema callback

There is deliberately no ash_typescript-specific callback for validation schemas. Third-party types can't be expected to implement one, so commonly used official Ash libraries are supported in-tree, and everything else uses a NewType or config.

Choosing an Approach

ApproachUse When
Ash.Type.NewType with constraintsYou control the type and it fits the NewType model — covers the type and the schema
Storage-derived defaultThe type is hand-rolled and a permissive schema is acceptable
zod_mapping_overrides / valibot_mapping_overridesYou need precision or raw library syntax (brands, .refine())
zod_import_into_generated / valibot_import_into_generatedThe override should reuse a schema you maintain in TypeScript

Untyped Map Type Configuration

By default, AshTypescript generates Record<string, any> for map-like types without field constraints. You can configure this to use stricter types.

Configuration

# config/config.exs
config :ash_typescript,
  # Default - allows any value type (more permissive)
  untyped_map_type: "Record<string, any>"

  # Stricter - requires type checking before use
  # untyped_map_type: "Record<string, unknown>"

  # Custom - use your own type definition
  # untyped_map_type: "MyCustomMapType"

What Gets Affected

This configuration applies to all map-like types without field constraints:

Maps with field constraints are NOT affected and will still generate typed objects.

Type Safety Comparison

With Record<string, any> (default):

// More permissive - values can be used directly
const todo = await getTodo({ fields: ["id", "customData"] });
if (todo.success && todo.data.customData) {
  const value = todo.data.customData.someField;  // OK - no error
  console.log(value.toUpperCase());              // Runtime error if not a string!
}

With Record<string, unknown> (stricter):

// Stricter - requires type checking before use
const todo = await getTodo({ fields: ["id", "customData"] });
if (todo.success && todo.data.customData) {
  const value = todo.data.customData.someField;     // Type: unknown
  console.log(value.toUpperCase());                 // ❌ TypeScript error!

  // Must check type first
  if (typeof value === 'string') {
    console.log(value.toUpperCase());               // ✅ OK
  }
}

When to Use Each Option

OptionUse When
Record<string, any>Maximum flexibility, working with dynamic data, backward compatibility
Record<string, unknown>Maximum type safety, new projects, catching potential runtime errors at compile time

Custom Type Imports

Import custom TypeScript modules into the generated code:

config :ash_typescript,
  import_into_generated: [
    %{
      import_name: "CustomTypes",
      file: "./customTypes"
    },
    %{
      import_name: "MyAppConfig",
      file: "./myAppConfig"
    }
  ]

This generates:

import * as CustomTypes from "./customTypes";
import * as MyAppConfig from "./myAppConfig";

Import Configuration Options

OptionTypeDescription
import_namestringName to use for the import (e.g., CustomTypes)
filestringRelative path to the module file (e.g., ./customTypes)

Next Steps