Skip to content

feat: Add generic defaults to generated types - #110

Open
TimoLehnertz wants to merge 2 commits into
madonoharu:mainfrom
TimoLehnertz:feature/generic-defaults
Open

feat: Add generic defaults to generated types#110
TimoLehnertz wants to merge 2 commits into
madonoharu:mainfrom
TimoLehnertz:feature/generic-defaults

Conversation

@TimoLehnertz

@TimoLehnertz TimoLehnertz commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Declare a default type parameter with its default

A default on a Rust type parameter never reached TypeScript:

struct Foo<T = String>(T);
export type Foo<T> = T;            // before
export type Foo<T = string> = T;   // after

The declaration path never read syn::TypeParam::default. #67 touched defaults from the other side — they broke the Rust build — and the DECL assertions added with it snapshotted whatever came out at the time, so those expectations are updated here.

What changes

type_params on the three decls goes from Vec<String> to Vec<TsTypeParam>:

pub struct TsTypeParam { pub name: String, pub default: Option<TsType> }

Name and default have to be separate because a parameter is written three ways and only one may carry a default: the declaration (A, B = number), the reference a namespaced enum unions over (Spaced.A<T>, where a default is a syntax error), and identity — the "is this ref my own parameter" filter, the affix exception list, prefix_type_refs — which matches on .name.

That distinction is the whole difficulty. Formatting "T = number" into the existing Vec<String> breaks a #[tsify(namespace)] enum three ways at once: T hoisted into a __SpacedT alias as if foreign, the affix applied to it, and Spaced.A<T = number> in the union, which does not parse.

Defaults render through TsType::from_syn_type like any other type — T = Vec<Option<u32>> becomes T = (number | null)[], or | undefined under js. #[declare] aliases get the same, and #[tsify(type_params = "T = string")] keeps working verbatim.

Two cases where a default can't be kept. One that names a parameter no field mentions is dropped, since that parameter is declared nowhere to point at; and then so is every default before it, because TypeScript only allows defaults on a trailing run — <B = number, C> is not valid.

Breaking

If you use a default parameter, its .d.ts changes. A reference naming every argument is unaffected; one that left the defaulted argument out was a TS2314 before and now resolves.

While #76 is open this makes that bug quieter: fn bar(foo: Ts<Foo<i64>>) still writes bar(foo: Foo), which a T = String default resolves to Foo<string> — silently wrong where it used to fail loudly. Checked with tsc --strict both ways. An argument for landing #76's fix alongside this, not against it.

Testing

tests/generics.rs covers nested defaults, a default naming another parameter, both drop cases, the namespace split and the type_params override; tsify-macros covers #[declare]. tests-e2e/test_defaults1 builds real wasm and its .d.ts passes tsc --noEmit --strict. All six existing e2e references are byte-identical.

If this lands after #109

That PR adds Decl::type_params() and name_type_params(), which resolve each declared parameter back to a Rust ident by string. Rebasing needs two edits: Decl::type_params() returns &[TsTypeParam], and param.ident == *declared becomes param.ident == declared.name. The stale comparison is a type error rather than a silent fallback, so it cannot pass unnoticed. The second CHANGELOG bullet also goes, since the masking it describes is gone once both are in.

Disclaimer

This PR was created by using claude

@madonoharu

madonoharu commented Aug 29, 2026

Copy link
Copy Markdown
Owner

The hard part of this one holds up, so let me start there rather than bury it.

TypeScript only allows defaults on a trailing run of parameters, and dropping one in the middle has to take every default before it. The core trailing-default logic held up across everything I tried beyond the cases you already test: the undeclared parameter nested inside the default (B = Vec<A>, B = Option<Vec<A>>), four parameters with defaults on the last two, a drop that lands on the trailing parameter so the earlier default can stay, and lifetimes and const parameters mixed in. All correct. #[tsify(type_prefix)] reaching into a default is right too, and the e2e reference passes tsc --strict.

Everything below was reproduced on 9cec4d2.

Three symptoms of one gap

A default is rendered as a type, but it is not propagated through the code paths that resolve types as references. declares_every_parameter_it_names does walk the default, but it only judges refs whose source is TypeParam — anything else passes unconditionally. The namespace's alias collection (decl.rs:252) and prefix_type_refs (decl.rs:302) walk type_ann and stop there.

A concrete type whose TypeScript name collides with a parameter. Valid Rust:

#[derive(Tsify)] pub struct T { pub z: u32 }

#[derive(Tsify)] struct Later<U = crate::T, T = String> { u: U, t: T }
#[derive(Tsify)] struct Earlier<T, U = crate::T> { t: T, u: U }
export interface Later<U = T, T = string> { ... }   // error TS2744
export interface Earlier<T, U = T> { ... }          // no error, wrong type

crate::T loses its qualification when it renders, and its source is not TypeParam, so the check waves it through. Later is rejected by tsc: a default may only reference a parameter declared before it. Earlier is worse — it compiles, and T now means the parameter rather than the interface. #[declare] renders its defaults through the same conversion, so #[declare] type Later<U = crate::T, T = String> emits the same thing.

A default inside a namespaced enum resolves against the namespace.

#[derive(Tsify)] pub struct Error { pub m: String }

#[derive(Tsify)]
#[tsify(namespace)]
enum Outcome<T = crate::Error> { Done(T), Error(String) }
declare namespace Outcome {
    export type Done<T = Error> = { Done: T };
    export type Error = { Error: string };
}

export type Outcome<T = Error> = Outcome.Done<T> | Outcome.Error;

The same written default means two different types: inside the namespace Error is the sibling variant, outside it is the interface. Nothing in the declaration is a syntax error, so it surfaces only where the two meet — Outcome.Done's T has no m. Spaced cannot see this because nothing in it collides.

A renamed declaration named in a default. #[tsify(rename = "Renamed")] struct Original used as struct UsesRenamed<T = Original> emits UsesRenamed<T = Original>, and Original is declared nowhere: TS2304. Same shape as #94 and #103, and a new instance of it. The rename caveat in 0.5.8 says to point each reference at the new name with #[tsify(type = "...")], and a parameter default has no such attribute — though #[tsify(type_params = "T = Renamed")] works, at the cost of writing the whole list by hand.

One aside, so it does not surprise you while testing: attrs.rs splits type_params on every comma, so a compound default like "T = Record<string, number>" becomes two parameters. main is already broken for that input, so it is neither yours nor a blocker here. Filed as #112.

On #76

Until #76 is fixed, merging this first would turn a visible diagnostic into a silently wrong default. That is a sequencing constraint for me, not a defect for you to address in this PR — and your reading in the description already called it. The result is a little more concerning than your description suggests. With struct Envelope<T = String> and fn takes(v: Ts<Envelope<i64>>):

signature what a consumer sees
main takes(v: Envelope) with Envelope<T> TS2314
this branch takes(v: Envelope) with Envelope<T = string> Envelope<string>, no diagnostic

The Rust type is Envelope<i64>. The error that stopped the build is replaced by a wrong type that does not; I checked that pattern on its own and it type-checks clean.

What would unblock it

I would like this in. Two conditions:

  1. The three items in the first section — a default is propagated through the code paths that resolve types as references, with the collision check evaluating every ref rather than only TypeParam ones, plus the namespace alias collection and prefix_type_refs. Derive and #[declare] both.
  2. A regression test per item, in whatever form you prefer.

Everything else is mine. I am responsible for the merge order relative to #76, and for the type_params aside. #111 landed a pair of e2e crates that share one source across the default and js features; rebasing would pick them up and a defaults case would fit the same shape, but that is a suggestion, not a requirement.

The parameter-list handling here is careful, and the three items are one piece of missing wiring rather than three separate mistakes.

Timo Lehnertz and others added 2 commits August 29, 2026 14:39
A default is read where the parameters are, so its names resolve against
the parameter list before the declarations around it. The first cut
rendered a default as a type and stopped there, which left three ways for
one to reach something other than the Rust it came from. All three are the
same missing step, so `resolve_defaults` now makes it once, next to the
`TsTypeParam` it settles, for the derive and `#[declare]` alike.

**A type whose TypeScript name a parameter has taken.** The check only
judged refs whose source is `TypeParam`, so a concrete type went through
unexamined:

    struct Later<U = crate::T, T = String>    // export interface Later<U = T, T = string>
    struct Earlier<T, U = crate::T>           // export interface Earlier<T, U = T>

`crate::T` loses its qualification when it renders. `Later` is a `TS2744` --
a default may only name a parameter declared before it -- and `Earlier` is
worse, because it compiles and `T` now means the parameter. Neither can be
spelled around inside a parameter list, where a parameter shadows, so the
default goes and the trailing-run rule takes the defaults before it.

**A default inside a namespace.** The alias collection and `prefix_type_refs`
walked `type_ann` and nothing else, so a default was left to resolve against
the namespace it is printed in:

    enum Outcome<T = crate::Error> { Done(T), Error(String) }

`Outcome.Done`'s `T` meant the sibling variant rather than the interface --
nothing in the declaration is a syntax error, so it surfaced only where the
two met. A default is collected and rewritten like any other reference now,
and reaches the same `__OutcomeError` alias.

**A renamed declaration named in a default** is not fixed here, and is not
particular to defaults: a field of the same type emits the same dangling
`Original`, which is madonoharu#103. Both are pinned side by side so the day madonoharu#103
lands, they move together. `#[tsify(type_params = "T = Renamed")]` says it
in the meantime.

The e2e crate follows madonoharu#111's shape -- one source built under both features,
since a default renders like any other type and `Option` and `HashMap`
inside one move with the feature. It also records what a default does to
madonoharu#76: the signature still loses its argument, and `Wrapper` now resolves
through the default rather than failing, so that line should change when madonoharu#76
is fixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@TimoLehnertz
TimoLehnertz force-pushed the feature/generic-defaults branch from 9cec4d2 to bf00c4e Compare August 29, 2026 12:45
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